Files
harmony/opnsense-codegen/src/api_codegen.rs
Jean-Gabriel Gill-Couture 35696c24b3 chore: cargo fmt, update OPNsense example documentation
- Run cargo fmt across opnsense-api, opnsense-config, opnsense-codegen
  (fixes formatting in generated files and hand-written modules)
- Update examples/opnsense/README.md: replace stale VirtualBox docs
  with current API key + cargo run instructions
- Update examples/opnsense_vm_integration/README.md: document
  idempotency test (run twice, assert zero duplicates), add
  build/opnsense-e2e.sh usage instructions
2026-04-06 15:56:59 -04:00

223 lines
8.5 KiB
Rust

//! Generates typed API client wrapper modules from controller + model IR.
//!
//! For each controller, produces a Rust module with a typed API struct
//! that wraps `OpnsenseClient` with proper request/response types.
//! The generated methods handle JSON envelope wrapping internally so
//! callers just pass the model struct directly.
use crate::controller_parser::{ActionKind, ControllerIR, EntityIR};
use heck::{ToPascalCase, ToSnakeCase};
use std::fmt::{Result as FmtResult, Write};
/// Generate a typed API client module for the given controller.
pub fn generate_api_module(controller: &ControllerIR) -> String {
let mut out = String::new();
let _ = write_api_module(&mut out, controller);
out
}
fn write_api_module(out: &mut String, controller: &ControllerIR) -> FmtResult {
let module = &controller.module;
let ctrl = &controller.controller;
writeln!(
out,
"//! Auto-generated typed API client for OPNsense `{module}/{ctrl}`."
)?;
writeln!(out, "//!")?;
writeln!(out, "//! **DO NOT EDIT** — produced by opnsense-codegen")?;
writeln!(out)?;
writeln!(out, "use crate::client::OpnsenseClient;")?;
writeln!(out, "use crate::error::Error;")?;
writeln!(
out,
"use crate::response::{{SearchResponse, SearchRow, StatusResponse, UuidResponse}};"
)?;
writeln!(out)?;
let struct_name = format!("{}Api", controller.controller.to_pascal_case());
// Generate envelope wrapper structs for each entity
for entity in &controller.entities {
if entity
.actions
.iter()
.any(|a| matches!(a, ActionKind::Add | ActionKind::Set))
{
let wrapper_name = format!("{}Envelope", entity.name);
writeln!(out, "#[derive(serde::Serialize)]")?;
writeln!(out, "struct {wrapper_name}<'a, T: serde::Serialize> {{")?;
writeln!(out, " #[serde(rename = \"{}\")]", entity.body_key)?;
writeln!(out, " inner: &'a T,")?;
writeln!(out, "}}")?;
writeln!(out)?;
}
}
writeln!(out, "/// Typed API client for `{module}/{ctrl}` endpoints.")?;
writeln!(out, "pub struct {struct_name}<'a> {{")?;
writeln!(out, " client: &'a OpnsenseClient,")?;
writeln!(out, "}}")?;
writeln!(out)?;
writeln!(out, "impl<'a> {struct_name}<'a> {{")?;
writeln!(out, " pub fn new(client: &'a OpnsenseClient) -> Self {{")?;
writeln!(out, " Self {{ client }}")?;
writeln!(out, " }}")?;
for entity in &controller.entities {
write_entity_methods(out, controller, entity)?;
}
for action in &controller.standalone_actions {
write_standalone_action(out, controller, action)?;
}
writeln!(out, "}}")?;
Ok(())
}
fn write_entity_methods(
out: &mut String,
controller: &ControllerIR,
entity: &EntityIR,
) -> FmtResult {
let entity_snake = entity.name.to_snake_case();
let module = &controller.module;
let ctrl = &controller.controller;
let entity_name = &entity.name;
let wrapper_name = format!("{}Envelope", entity.name);
for action in &entity.actions {
writeln!(out)?;
match action {
ActionKind::Search => {
writeln!(out, " /// Search {entity_snake}s.")?;
writeln!(out, " ///")?;
writeln!(
out,
" /// Returns a typed [`SearchResponse`] with [`SearchRow`] entries."
)?;
writeln!(
out,
" /// Use `row.label()` for the description and `row.uuid` for the ID."
)?;
writeln!(
out,
" pub async fn search_{entity_snake}s(&self) -> Result<SearchResponse<SearchRow>, Error> {{"
)?;
writeln!(
out,
" self.client.search_items(\"{module}\", \"{ctrl}\", \"{entity_name}\").await"
)?;
writeln!(out, " }}")?;
}
ActionKind::Get => {
writeln!(out, " /// Get a single {entity_snake} by UUID.")?;
writeln!(
out,
" pub async fn get_{entity_snake}<R: serde::de::DeserializeOwned + std::fmt::Debug>(&self, uuid: &str) -> Result<R, Error> {{"
)?;
writeln!(
out,
" self.client.get_item(\"{module}\", \"{ctrl}\", \"{entity_name}\", uuid).await"
)?;
writeln!(out, " }}")?;
}
ActionKind::Add => {
writeln!(out, " /// Add a new {entity_snake}.")?;
writeln!(out, " ///")?;
writeln!(
out,
" /// Pass the model struct directly — the JSON envelope"
)?;
writeln!(
out,
" /// (`{{\"{}\": {{...}}}}`) is handled automatically.",
entity.body_key
)?;
writeln!(
out,
" pub async fn add_{entity_snake}(&self, {entity_snake}: &(impl serde::Serialize + Sync)) -> Result<UuidResponse, Error> {{"
)?;
writeln!(
out,
" self.client.add_item(\"{module}\", \"{ctrl}\", \"{entity_name}\", &{wrapper_name} {{ inner: {entity_snake} }}).await"
)?;
writeln!(out, " }}")?;
}
ActionKind::Set => {
writeln!(out, " /// Update a {entity_snake} by UUID.")?;
writeln!(out, " ///")?;
writeln!(
out,
" /// Pass the model struct directly — the JSON envelope is handled automatically."
)?;
writeln!(
out,
" pub async fn set_{entity_snake}(&self, uuid: &str, {entity_snake}: &(impl serde::Serialize + Sync)) -> Result<StatusResponse, Error> {{"
)?;
writeln!(
out,
" self.client.set_item(\"{module}\", \"{ctrl}\", \"{entity_name}\", uuid, &{wrapper_name} {{ inner: {entity_snake} }}).await"
)?;
writeln!(out, " }}")?;
}
ActionKind::Del => {
writeln!(out, " /// Delete a {entity_snake} by UUID.")?;
writeln!(
out,
" pub async fn del_{entity_snake}(&self, uuid: &str) -> Result<StatusResponse, Error> {{"
)?;
writeln!(
out,
" self.client.del_item(\"{module}\", \"{ctrl}\", \"{entity_name}\", uuid).await"
)?;
writeln!(out, " }}")?;
}
ActionKind::Toggle => {
writeln!(out, " /// Toggle a {entity_snake} enabled/disabled.")?;
writeln!(
out,
" pub async fn toggle_{entity_snake}(&self, uuid: &str, enabled: Option<&str>) -> Result<StatusResponse, Error> {{"
)?;
writeln!(out, " let cmd = match enabled {{")?;
writeln!(
out,
" Some(e) => format!(\"toggle{entity_name}/{{}}/{{}}\", uuid, e),"
)?;
writeln!(
out,
" None => format!(\"toggle{entity_name}/{{}}\", uuid),"
)?;
writeln!(out, " }};")?;
writeln!(
out,
" self.client.post_typed(\"{module}\", \"{ctrl}\", &cmd, None::<&()>).await"
)?;
writeln!(out, " }}")?;
}
}
}
Ok(())
}
fn write_standalone_action(out: &mut String, controller: &ControllerIR, action: &str) -> FmtResult {
let module = &controller.module;
let ctrl = &controller.controller;
let fn_name = action.to_snake_case();
writeln!(out)?;
writeln!(out, " /// Execute the `{action}` action.")?;
writeln!(
out,
" pub async fn {fn_name}(&self) -> Result<StatusResponse, Error> {{"
)?;
writeln!(
out,
" self.client.post_typed(\"{module}\", \"{ctrl}\", \"{action}\", None::<&()>).await"
)?;
writeln!(out, " }}")?;
Ok(())
}