use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use apimock_routing::{RoutingError, RuleSet};
use crate::{
Config,
error::{ApplyError, ConfigError, SaveError, WorkspaceError},
view::{
ApplyResult, ConfigFileKind, ConfigFileView, ConfigNodeView, Diagnostic, EditCommand,
EditValue, NodeId, NodeKind, NodeValidation, SaveResult, Severity, ValidationIssue,
ValidationReport, WorkspaceSnapshot,
},
};
pub struct Workspace {
root_path: PathBuf,
config: Config,
ids: IdIndex,
diagnostics: Vec<Diagnostic>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum NodeAddress {
Root,
RuleSet { rule_set: usize },
Rule { rule_set: usize, rule: usize },
Respond { rule_set: usize, rule: usize },
Middleware { middleware: usize },
FallbackRespondDir,
}
#[derive(Default)]
struct IdIndex {
id_to_address: HashMap<NodeId, NodeAddress>,
address_to_id: HashMap<NodeAddress, NodeId>,
}
impl IdIndex {
fn insert(&mut self, address: NodeAddress) -> NodeId {
if let Some(&id) = self.address_to_id.get(&address) {
return id;
}
let id = NodeId::new();
self.id_to_address.insert(id, address);
self.address_to_id.insert(address, id);
id
}
#[allow(dead_code)]
fn lookup(&self, id: NodeId) -> Option<NodeAddress> {
self.id_to_address.get(&id).copied()
}
fn id_for(&self, address: NodeAddress) -> Option<NodeId> {
self.address_to_id.get(&address).copied()
}
}
impl Workspace {
pub fn load(root: PathBuf) -> Result<Self, WorkspaceError> {
let resolved = resolve_root(&root)?;
let config_path_string = resolved.to_string_lossy().into_owned();
let config = Config::new(Some(&config_path_string), None).map_err(WorkspaceError::from)?;
let mut workspace = Self {
root_path: resolved,
config,
ids: IdIndex::default(),
diagnostics: Vec::new(),
};
workspace.seed_ids();
Ok(workspace)
}
fn seed_ids(&mut self) {
self.ids.insert(NodeAddress::Root);
self.ids.insert(NodeAddress::FallbackRespondDir);
for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
self.ids.insert(NodeAddress::RuleSet { rule_set: rs_idx });
for (rule_idx, _rule) in rule_set.rules.iter().enumerate() {
self.ids.insert(NodeAddress::Rule {
rule_set: rs_idx,
rule: rule_idx,
});
self.ids.insert(NodeAddress::Respond {
rule_set: rs_idx,
rule: rule_idx,
});
}
}
if let Some(paths) = self.config.service.middlewares_file_paths.as_ref() {
for mw_idx in 0..paths.len() {
self.ids
.insert(NodeAddress::Middleware { middleware: mw_idx });
}
}
}
pub fn snapshot(&self) -> WorkspaceSnapshot {
let mut files: Vec<ConfigFileView> = Vec::new();
if let Some(root_nodes) = self.root_file_nodes() {
files.push(root_nodes);
}
for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
files.push(self.rule_set_file_view(rs_idx, rule_set));
}
if let Some(paths) = self.config.service.middlewares_file_paths.as_ref() {
for (mw_idx, mw_path) in paths.iter().enumerate() {
let abs = self.resolve_relative(mw_path);
let id = self
.ids
.id_for(NodeAddress::Middleware { middleware: mw_idx })
.expect("middleware id seeded at load");
let node = ConfigNodeView {
id,
source_file: abs.clone(),
toml_path: format!("service.middlewares[{}]", mw_idx),
display_name: mw_path.clone(),
kind: NodeKind::Script,
validation: NodeValidation::ok(),
};
files.push(ConfigFileView {
path: abs.clone(),
display_name: file_basename(&abs),
kind: ConfigFileKind::Middleware,
nodes: vec![node],
});
}
}
let routes = apimock_routing::view::RouteCatalogSnapshot::empty();
WorkspaceSnapshot {
files,
routes,
diagnostics: self.diagnostics.clone(),
}
}
pub fn apply(&mut self, cmd: EditCommand) -> Result<ApplyResult, ApplyError> {
let (changed_nodes, requires_reload) = match cmd {
EditCommand::AddRuleSet { path } => {
let ids = self.cmd_add_rule_set(path)?;
(ids, true)
}
EditCommand::RemoveRuleSet { id } => {
let ids = self.cmd_remove_rule_set(id)?;
(ids, true)
}
EditCommand::AddRule { parent, rule } => {
let ids = self.cmd_add_rule(parent, rule)?;
(ids, true)
}
EditCommand::UpdateRule { id, rule } => {
let ids = self.cmd_update_rule(id, rule)?;
(ids, true)
}
EditCommand::DeleteRule { id } => {
let ids = self.cmd_delete_rule(id)?;
(ids, true)
}
EditCommand::MoveRule { id, new_index } => {
let ids = self.cmd_move_rule(id, new_index)?;
(ids, true)
}
EditCommand::UpdateRespond { id, respond } => {
let ids = self.cmd_update_respond(id, respond)?;
(ids, true)
}
EditCommand::UpdateRootSetting { key, value } => {
let ids = self.cmd_update_root_setting(key, value)?;
(ids, true)
}
};
let diagnostics = self.collect_diagnostics();
Ok(ApplyResult {
changed_nodes,
diagnostics,
requires_reload,
})
}
fn cmd_add_rule_set(&mut self, path: String) -> Result<Vec<NodeId>, ApplyError> {
let relative_dir = self.config_relative_dir().map_err(internal_path_err)?;
let joined = Path::new(&relative_dir).join(&path);
let path_str = joined.to_str().ok_or_else(|| ApplyError::InvalidPayload {
reason: format!(
"path contains non-UTF-8 bytes: {}",
joined.to_string_lossy()
),
})?;
let next_idx = self.config.service.rule_sets.len();
let new_rule_set = RuleSet::new(path_str, relative_dir.as_str(), next_idx)
.map_err(|e| ApplyError::InvalidPayload {
reason: format!("failed to load rule set `{}`: {}", path, e),
})?;
let file_paths = self
.config
.service
.rule_sets_file_paths
.get_or_insert_with(Vec::new);
file_paths.push(path.clone());
let new_len = self.config.service.rule_sets.len() + 1;
self.config.service.rule_sets.push(new_rule_set);
let rs_addr = NodeAddress::RuleSet {
rule_set: next_idx,
};
let rs_id = self.ids.insert(rs_addr);
let mut changed = vec![rs_id];
let new_rs = &self.config.service.rule_sets[next_idx];
for rule_idx in 0..new_rs.rules.len() {
let r_id = self.ids.insert(NodeAddress::Rule {
rule_set: next_idx,
rule: rule_idx,
});
let resp_id = self.ids.insert(NodeAddress::Respond {
rule_set: next_idx,
rule: rule_idx,
});
changed.push(r_id);
changed.push(resp_id);
}
debug_assert_eq!(new_len, self.config.service.rule_sets.len());
Ok(changed)
}
fn cmd_remove_rule_set(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
let NodeAddress::RuleSet { rule_set: idx } = addr else {
return Err(ApplyError::WrongNodeKind {
id,
reason: "expected a rule set id".to_owned(),
});
};
let len = self.config.service.rule_sets.len();
if idx >= len {
return Err(ApplyError::InvalidPayload {
reason: format!("rule set index {} out of range (len={})", idx, len),
});
}
let mut changed: Vec<NodeId> = Vec::new();
changed.push(id);
if let Some(removed_rs) = self.config.service.rule_sets.get(idx) {
for rule_idx in 0..removed_rs.rules.len() {
if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
rule_set: idx,
rule: rule_idx,
}) {
changed.push(r_id);
}
if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
rule_set: idx,
rule: rule_idx,
}) {
changed.push(resp_id);
}
}
}
self.config.service.rule_sets.remove(idx);
if let Some(paths) = self.config.service.rule_sets_file_paths.as_mut() {
if idx < paths.len() {
paths.remove(idx);
}
}
self.shift_rule_sets_down(idx);
for shifted_idx in idx..self.config.service.rule_sets.len() {
if let Some(shifted_id) = self
.ids
.id_for(NodeAddress::RuleSet {
rule_set: shifted_idx,
})
{
if !changed.contains(&shifted_id) {
changed.push(shifted_id);
}
}
}
Ok(changed)
}
fn cmd_add_rule(
&mut self,
parent: NodeId,
rule_payload: crate::view::RulePayload,
) -> Result<Vec<NodeId>, ApplyError> {
let addr = self
.ids
.lookup(parent)
.ok_or(ApplyError::UnknownNode { id: parent })?;
let NodeAddress::RuleSet { rule_set: rs_idx } = addr else {
return Err(ApplyError::WrongNodeKind {
id: parent,
reason: "expected a rule set id (parent for AddRule must be a rule set)".to_owned(),
});
};
let rule_set = self
.config
.service
.rule_sets
.get_mut(rs_idx)
.ok_or_else(|| ApplyError::InvalidPayload {
reason: format!("rule set index {} out of range", rs_idx),
})?;
let new_rule = build_rule_from_payload(rule_payload, rule_set, rs_idx)?;
let new_rule_idx = rule_set.rules.len();
rule_set.rules.push(new_rule);
let r_id = self.ids.insert(NodeAddress::Rule {
rule_set: rs_idx,
rule: new_rule_idx,
});
let resp_id = self.ids.insert(NodeAddress::Respond {
rule_set: rs_idx,
rule: new_rule_idx,
});
Ok(vec![parent, r_id, resp_id])
}
fn cmd_update_rule(
&mut self,
id: NodeId,
rule_payload: crate::view::RulePayload,
) -> Result<Vec<NodeId>, ApplyError> {
let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
let NodeAddress::Rule {
rule_set: rs_idx,
rule: rule_idx,
} = addr
else {
return Err(ApplyError::WrongNodeKind {
id,
reason: "expected a rule id".to_owned(),
});
};
let rule_set = self
.config
.service
.rule_sets
.get_mut(rs_idx)
.ok_or_else(|| ApplyError::InvalidPayload {
reason: format!("rule set index {} out of range", rs_idx),
})?;
let new_rule = build_rule_from_payload(rule_payload, rule_set, rs_idx)?;
*rule_set
.rules
.get_mut(rule_idx)
.ok_or_else(|| ApplyError::InvalidPayload {
reason: format!("rule index {} out of range", rule_idx),
})? = new_rule;
let resp_id = self
.ids
.id_for(NodeAddress::Respond {
rule_set: rs_idx,
rule: rule_idx,
})
.unwrap_or_else(NodeId::new);
Ok(vec![id, resp_id])
}
fn cmd_delete_rule(&mut self, id: NodeId) -> Result<Vec<NodeId>, ApplyError> {
let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
let NodeAddress::Rule {
rule_set: rs_idx,
rule: rule_idx,
} = addr
else {
return Err(ApplyError::WrongNodeKind {
id,
reason: "expected a rule id".to_owned(),
});
};
let rule_set = self
.config
.service
.rule_sets
.get_mut(rs_idx)
.ok_or_else(|| ApplyError::InvalidPayload {
reason: format!("rule set index {} out of range", rs_idx),
})?;
if rule_idx >= rule_set.rules.len() {
return Err(ApplyError::InvalidPayload {
reason: format!("rule index {} out of range", rule_idx),
});
}
let mut changed: Vec<NodeId> = Vec::new();
changed.push(id);
if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
rule_set: rs_idx,
rule: rule_idx,
}) {
changed.push(resp_id);
}
rule_set.rules.remove(rule_idx);
self.shift_rules_down(rs_idx, rule_idx);
let new_rule_count = self.config.service.rule_sets[rs_idx].rules.len();
for shifted_idx in rule_idx..new_rule_count {
if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
rule_set: rs_idx,
rule: shifted_idx,
}) {
if !changed.contains(&r_id) {
changed.push(r_id);
}
}
if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
rule_set: rs_idx,
rule: shifted_idx,
}) {
if !changed.contains(&resp_id) {
changed.push(resp_id);
}
}
}
Ok(changed)
}
fn cmd_move_rule(&mut self, id: NodeId, new_index: usize) -> Result<Vec<NodeId>, ApplyError> {
let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
let NodeAddress::Rule {
rule_set: rs_idx,
rule: old_idx,
} = addr
else {
return Err(ApplyError::WrongNodeKind {
id,
reason: "expected a rule id".to_owned(),
});
};
let rule_set = self
.config
.service
.rule_sets
.get_mut(rs_idx)
.ok_or_else(|| ApplyError::InvalidPayload {
reason: format!("rule set index {} out of range", rs_idx),
})?;
if old_idx >= rule_set.rules.len() || new_index >= rule_set.rules.len() {
return Err(ApplyError::InvalidPayload {
reason: format!(
"move out of bounds: old_idx={}, new_index={}, len={}",
old_idx,
new_index,
rule_set.rules.len()
),
});
}
if old_idx == new_index {
return Ok(vec![id]);
}
let rule = rule_set.rules.remove(old_idx);
rule_set.rules.insert(new_index, rule);
self.reorder_rule_ids(rs_idx, old_idx, new_index);
let lo = old_idx.min(new_index);
let hi = old_idx.max(new_index);
let mut changed: Vec<NodeId> = Vec::new();
for idx in lo..=hi {
if let Some(r_id) = self.ids.id_for(NodeAddress::Rule {
rule_set: rs_idx,
rule: idx,
}) {
changed.push(r_id);
}
if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
rule_set: rs_idx,
rule: idx,
}) {
changed.push(resp_id);
}
}
Ok(changed)
}
fn cmd_update_respond(
&mut self,
id: NodeId,
respond: crate::view::RespondPayload,
) -> Result<Vec<NodeId>, ApplyError> {
let addr = self.ids.lookup(id).ok_or(ApplyError::UnknownNode { id })?;
let NodeAddress::Respond {
rule_set: rs_idx,
rule: rule_idx,
} = addr
else {
return Err(ApplyError::WrongNodeKind {
id,
reason: "expected a respond id".to_owned(),
});
};
let rule = self
.config
.service
.rule_sets
.get_mut(rs_idx)
.and_then(|rs| rs.rules.get_mut(rule_idx))
.ok_or_else(|| ApplyError::InvalidPayload {
reason: format!(
"rule at rule_set={}, rule={} not found",
rs_idx, rule_idx
),
})?;
rule.respond = build_respond_from_payload(respond);
let rule_set = &self.config.service.rule_sets[rs_idx];
let derived = rule_set.rules[rule_idx].compute_derived_fields(rule_set, rule_idx, rs_idx);
self.config.service.rule_sets[rs_idx].rules[rule_idx] = derived;
Ok(vec![id])
}
fn cmd_update_root_setting(
&mut self,
key: crate::view::RootSettingKey,
value: EditValue,
) -> Result<Vec<NodeId>, ApplyError> {
use crate::view::RootSettingKey::*;
match key {
ListenerIpAddress => {
let s = value_as_string(&value)?;
let listener = self.config.listener.get_or_insert_with(Default::default);
listener.ip_address = s;
}
ListenerPort => {
let n = value_as_integer(&value)?;
if !(0..=u16::MAX as i64).contains(&n) {
return Err(ApplyError::InvalidPayload {
reason: format!("port {} not in 0..=65535", n),
});
}
let listener = self.config.listener.get_or_insert_with(Default::default);
listener.port = n as u16;
}
ServiceFallbackRespondDir => {
let s = value_as_string(&value)?;
self.config.service.fallback_respond_dir = s;
}
ServiceStrategy => {
let s = value_as_string(&value)?;
match s.as_str() {
"first_match" => {
self.config.service.strategy =
Some(apimock_routing::Strategy::FirstMatch);
}
other => {
return Err(ApplyError::InvalidPayload {
reason: format!("unknown strategy: {}", other),
});
}
}
}
}
let id = self
.ids
.id_for(NodeAddress::Root)
.expect("root id seeded at load");
Ok(vec![id])
}
fn shift_rule_sets_down(&mut self, removed_idx: usize) {
let new_rs_count = self.config.service.rule_sets.len();
let mut stale: Vec<NodeAddress> = Vec::new();
stale.push(NodeAddress::RuleSet {
rule_set: removed_idx,
});
for old_idx in removed_idx..new_rs_count + 1 {
stale.push(NodeAddress::RuleSet { rule_set: old_idx });
}
let mut to_migrate: Vec<(NodeId, NodeAddress)> = Vec::new();
for (&addr, &id) in self.ids.address_to_id.iter() {
match addr {
NodeAddress::RuleSet { rule_set } if rule_set >= removed_idx => {
to_migrate.push((id, addr));
}
NodeAddress::Rule { rule_set, .. } if rule_set >= removed_idx => {
to_migrate.push((id, addr));
}
NodeAddress::Respond { rule_set, .. } if rule_set >= removed_idx => {
to_migrate.push((id, addr));
}
_ => {}
}
}
for (id, addr) in &to_migrate {
self.ids.address_to_id.remove(addr);
self.ids.id_to_address.remove(id);
}
for (id, addr) in to_migrate {
let new_addr = match addr {
NodeAddress::RuleSet { rule_set } => {
if rule_set == removed_idx {
continue; }
NodeAddress::RuleSet {
rule_set: rule_set - 1,
}
}
NodeAddress::Rule { rule_set, rule } => {
if rule_set == removed_idx {
continue;
}
NodeAddress::Rule {
rule_set: rule_set - 1,
rule,
}
}
NodeAddress::Respond { rule_set, rule } => {
if rule_set == removed_idx {
continue;
}
NodeAddress::Respond {
rule_set: rule_set - 1,
rule,
}
}
other => other,
};
self.ids.id_to_address.insert(id, new_addr);
self.ids.address_to_id.insert(new_addr, id);
}
}
fn shift_rules_down(&mut self, rule_set_idx: usize, removed_rule_idx: usize) {
let mut to_migrate: Vec<(NodeId, NodeAddress)> = Vec::new();
for (&addr, &id) in self.ids.address_to_id.iter() {
match addr {
NodeAddress::Rule { rule_set, rule }
if rule_set == rule_set_idx && rule >= removed_rule_idx =>
{
to_migrate.push((id, addr));
}
NodeAddress::Respond { rule_set, rule }
if rule_set == rule_set_idx && rule >= removed_rule_idx =>
{
to_migrate.push((id, addr));
}
_ => {}
}
}
for (id, addr) in &to_migrate {
self.ids.address_to_id.remove(addr);
self.ids.id_to_address.remove(id);
}
for (id, addr) in to_migrate {
let new_addr = match addr {
NodeAddress::Rule { rule_set, rule } => {
if rule == removed_rule_idx {
continue;
}
NodeAddress::Rule {
rule_set,
rule: rule - 1,
}
}
NodeAddress::Respond { rule_set, rule } => {
if rule == removed_rule_idx {
continue;
}
NodeAddress::Respond {
rule_set,
rule: rule - 1,
}
}
other => other,
};
self.ids.id_to_address.insert(id, new_addr);
self.ids.address_to_id.insert(new_addr, id);
}
}
fn reorder_rule_ids(&mut self, rule_set_idx: usize, old_idx: usize, new_idx: usize) {
let rule_count = self.config.service.rule_sets[rule_set_idx].rules.len();
let mut rule_ids: Vec<Option<NodeId>> = (0..rule_count)
.map(|r| {
self.ids.id_for(NodeAddress::Rule {
rule_set: rule_set_idx,
rule: r,
})
})
.collect();
let mut resp_ids: Vec<Option<NodeId>> = (0..rule_count)
.map(|r| {
self.ids.id_for(NodeAddress::Respond {
rule_set: rule_set_idx,
rule: r,
})
})
.collect();
let moving_r = rule_ids.remove(old_idx);
rule_ids.insert(new_idx, moving_r);
let moving_resp = resp_ids.remove(old_idx);
resp_ids.insert(new_idx, moving_resp);
for r in 0..rule_count {
let rule_addr = NodeAddress::Rule {
rule_set: rule_set_idx,
rule: r,
};
let resp_addr = NodeAddress::Respond {
rule_set: rule_set_idx,
rule: r,
};
if let Some(prev_id) = self.ids.address_to_id.remove(&rule_addr) {
self.ids.id_to_address.remove(&prev_id);
}
if let Some(prev_id) = self.ids.address_to_id.remove(&resp_addr) {
self.ids.id_to_address.remove(&prev_id);
}
}
for (r, id_opt) in rule_ids.into_iter().enumerate() {
let addr = NodeAddress::Rule {
rule_set: rule_set_idx,
rule: r,
};
let id = id_opt.unwrap_or_else(NodeId::new);
self.ids.id_to_address.insert(id, addr);
self.ids.address_to_id.insert(addr, id);
}
for (r, id_opt) in resp_ids.into_iter().enumerate() {
let addr = NodeAddress::Respond {
rule_set: rule_set_idx,
rule: r,
};
let id = id_opt.unwrap_or_else(NodeId::new);
self.ids.id_to_address.insert(id, addr);
self.ids.address_to_id.insert(addr, id);
}
}
fn config_relative_dir(&self) -> Result<String, ConfigError> {
self.config.current_dir_to_parent_dir_relative_path()
}
fn collect_diagnostics(&self) -> Vec<Diagnostic> {
let mut out: Vec<Diagnostic> = Vec::new();
for (rs_idx, rule_set) in self.config.service.rule_sets.iter().enumerate() {
for (rule_idx, rule) in rule_set.rules.iter().enumerate() {
let nv = respond_node_validation(&rule.respond, rule_set, rule_idx, rs_idx);
if nv.ok {
continue;
}
let resp_id = self.ids.id_for(NodeAddress::Respond {
rule_set: rs_idx,
rule: rule_idx,
});
for issue in nv.issues {
out.push(Diagnostic {
node_id: resp_id,
file: Some(PathBuf::from(rule_set.file_path.as_str())),
severity: issue.severity,
message: issue.message,
});
}
}
}
if !Path::new(self.config.service.fallback_respond_dir.as_str()).exists() {
out.push(Diagnostic {
node_id: self.ids.id_for(NodeAddress::FallbackRespondDir),
file: Some(self.root_path.clone()),
severity: Severity::Error,
message: format!(
"fallback_respond_dir does not exist: {}",
self.config.service.fallback_respond_dir
),
});
}
out
}
pub fn validate(&self) -> ValidationReport {
let diagnostics = self.collect_diagnostics();
let is_valid = !diagnostics
.iter()
.any(|d| matches!(d.severity, Severity::Error));
ValidationReport {
diagnostics,
is_valid,
}
}
pub fn save(&mut self) -> Result<SaveResult, SaveError> {
Err(SaveError::Inconsistent {
reason: "Workspace::save is a Step-4 feature; not implemented in 5.1.0"
.to_owned(),
})
}
fn root_file_nodes(&self) -> Option<ConfigFileView> {
let mut nodes = Vec::new();
if let Some(root_id) = self.ids.id_for(NodeAddress::Root) {
nodes.push(ConfigNodeView {
id: root_id,
source_file: self.root_path.clone(),
toml_path: String::new(),
display_name: "apimock.toml".to_owned(),
kind: NodeKind::RootSetting,
validation: NodeValidation::ok(),
});
}
if let Some(fb_id) = self.ids.id_for(NodeAddress::FallbackRespondDir) {
nodes.push(ConfigNodeView {
id: fb_id,
source_file: self.root_path.clone(),
toml_path: "service.fallback_respond_dir".to_owned(),
display_name: self.config.service.fallback_respond_dir.clone(),
kind: NodeKind::FileNode,
validation: NodeValidation::ok(),
});
}
Some(ConfigFileView {
path: self.root_path.clone(),
display_name: file_basename(&self.root_path),
kind: ConfigFileKind::Root,
nodes,
})
}
fn rule_set_file_view(&self, rs_idx: usize, rule_set: &RuleSet) -> ConfigFileView {
let file_path = PathBuf::from(rule_set.file_path.as_str());
let mut nodes: Vec<ConfigNodeView> = Vec::new();
if let Some(rs_id) = self
.ids
.id_for(NodeAddress::RuleSet { rule_set: rs_idx })
{
nodes.push(ConfigNodeView {
id: rs_id,
source_file: file_path.clone(),
toml_path: String::new(),
display_name: file_basename(&file_path),
kind: NodeKind::RuleSet,
validation: NodeValidation::ok(),
});
}
for (rule_idx, rule) in rule_set.rules.iter().enumerate() {
if let Some(rule_id) = self.ids.id_for(NodeAddress::Rule {
rule_set: rs_idx,
rule: rule_idx,
}) {
let url_path_label = rule
.when
.request
.url_path
.as_ref()
.map(|u| u.value.as_str())
.unwrap_or_default();
let display = if url_path_label.is_empty() {
format!("Rule #{}", rule_idx + 1)
} else {
url_path_label.to_owned()
};
nodes.push(ConfigNodeView {
id: rule_id,
source_file: file_path.clone(),
toml_path: format!("rules[{}]", rule_idx),
display_name: display,
kind: NodeKind::Rule,
validation: NodeValidation::ok(),
});
}
if let Some(resp_id) = self.ids.id_for(NodeAddress::Respond {
rule_set: rs_idx,
rule: rule_idx,
}) {
nodes.push(ConfigNodeView {
id: resp_id,
source_file: file_path.clone(),
toml_path: format!("rules[{}].respond", rule_idx),
display_name: summarise_respond(&rule.respond),
kind: NodeKind::Respond,
validation: respond_node_validation(&rule.respond, rule_set, rule_idx, rs_idx),
});
}
}
ConfigFileView {
path: file_path.clone(),
display_name: file_basename(&file_path),
kind: ConfigFileKind::RuleSet,
nodes,
}
}
fn resolve_relative(&self, rel: &str) -> PathBuf {
match self.config.current_dir_to_parent_dir_relative_path() {
Ok(dir) => Path::new(&dir).join(rel),
Err(_) => PathBuf::from(rel),
}
}
pub fn config(&self) -> &Config {
&self.config
}
pub fn root_path(&self) -> &Path {
&self.root_path
}
}
fn summarise_respond(respond: &apimock_routing::Respond) -> String {
if let Some(p) = respond.file_path.as_ref() {
return format!("file: {}", p);
}
if let Some(t) = respond.text.as_ref() {
const LIMIT: usize = 40;
if t.chars().count() > LIMIT {
let truncated: String = t.chars().take(LIMIT).collect();
return format!("text: {}…", truncated);
}
return format!("text: {}", t);
}
if let Some(s) = respond.status.as_ref() {
return format!("status: {}", s);
}
"(empty)".to_owned()
}
fn respond_node_validation(
respond: &apimock_routing::Respond,
rule_set: &RuleSet,
rule_idx: usize,
rs_idx: usize,
) -> NodeValidation {
let mut issues: Vec<ValidationIssue> = Vec::new();
let any = respond.file_path.is_some() || respond.text.is_some() || respond.status.is_some();
if !any {
issues.push(ValidationIssue {
severity: Severity::Error,
message: "response requires at least one of file_path, text, or status".to_owned(),
});
}
if respond.file_path.is_some() && respond.text.is_some() {
issues.push(ValidationIssue {
severity: Severity::Error,
message: "file_path and text cannot both be set".to_owned(),
});
}
if respond.file_path.is_some() && respond.status.is_some() {
issues.push(ValidationIssue {
severity: Severity::Error,
message: "status cannot be combined with file_path (only with text)".to_owned(),
});
}
if let Some(file_path) = respond.file_path.as_ref() {
let dir_prefix = rule_set.dir_prefix();
let p = Path::new(dir_prefix.as_str()).join(file_path);
if !p.exists() {
issues.push(ValidationIssue {
severity: Severity::Error,
message: format!(
"file not found: {} (rule #{} in rule set #{})",
p.to_string_lossy(),
rule_idx + 1,
rs_idx + 1,
),
});
}
}
NodeValidation {
ok: issues.is_empty(),
issues,
}
}
fn file_basename(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned())
}
fn build_rule_from_payload(
payload: crate::view::RulePayload,
rule_set: &apimock_routing::RuleSet,
rs_idx: usize,
) -> Result<apimock_routing::Rule, ApplyError> {
use apimock_routing::rule_set::rule::Rule;
use apimock_routing::rule_set::rule::when::When;
use apimock_routing::rule_set::rule::when::request::{
Request, http_method::HttpMethod, url_path::UrlPathConfig,
};
let url_path_config = payload.url_path.as_ref().map(|s| UrlPathConfig::Simple(s.clone()));
let http_method = match payload.method.as_deref() {
Some("GET") | Some("get") => Some(HttpMethod::Get),
Some("POST") | Some("post") => Some(HttpMethod::Post),
Some("PUT") | Some("put") => Some(HttpMethod::Put),
Some("DELETE") | Some("delete") => Some(HttpMethod::Delete),
Some(other) => {
return Err(ApplyError::InvalidPayload {
reason: format!(
"unsupported HTTP method `{}` — supported: GET, POST, PUT, DELETE",
other
),
});
}
None => None,
};
let request = Request {
url_path_config,
url_path: None, http_method,
headers: None,
body: None,
};
let rule = Rule {
when: When { request },
respond: build_respond_from_payload(payload.respond),
};
Ok(rule.compute_derived_fields(rule_set, rule_set.rules.len(), rs_idx))
}
fn build_respond_from_payload(payload: crate::view::RespondPayload) -> apimock_routing::Respond {
apimock_routing::Respond {
file_path: payload.file_path,
csv_records_key: None,
text: payload.text,
status: payload.status,
status_code: None, headers: None,
delay_response_milliseconds: payload.delay_milliseconds,
}
}
fn value_as_string(value: &EditValue) -> Result<String, ApplyError> {
match value {
EditValue::String(s) => Ok(s.clone()),
EditValue::Enum(s) => Ok(s.clone()),
other => Err(ApplyError::InvalidPayload {
reason: format!("expected a string, got {:?}", other),
}),
}
}
fn value_as_integer(value: &EditValue) -> Result<i64, ApplyError> {
match value {
EditValue::Integer(n) => Ok(*n),
other => Err(ApplyError::InvalidPayload {
reason: format!("expected an integer, got {:?}", other),
}),
}
}
fn internal_path_err(err: ConfigError) -> ApplyError {
ApplyError::InvalidPayload {
reason: format!("internal path resolution failed: {}", err),
}
}
fn resolve_root(root: &Path) -> Result<PathBuf, WorkspaceError> {
if root.is_file() {
return Ok(root.to_path_buf());
}
if root.is_dir() {
let candidate = root.join("apimock.toml");
if candidate.is_file() {
return Ok(candidate);
}
return Err(WorkspaceError::InvalidRoot {
path: root.to_path_buf(),
reason: "directory does not contain apimock.toml".to_owned(),
});
}
Err(WorkspaceError::InvalidRoot {
path: root.to_path_buf(),
reason: "path does not exist".to_owned(),
})
}
#[allow(dead_code)]
fn routing_to_config(err: RoutingError) -> ConfigError {
ConfigError::from(err)
}
#[cfg(test)]
mod tests;