- 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
223 lines
8.5 KiB
Rust
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(())
|
|
}
|