Skip to main content

apimock_routing/
view.rs

1//! Read-only views on routing state for GUI tooling.
2//!
3//! # Stage-1 scope (5.0.0)
4//!
5//! Per the 5.0.0 brief, this module defines the *shape* of the read-only
6//! API that a future GUI will depend on. The types are declared with
7//! their fields and rustdoc-annotated responsibilities; populating them
8//! from a live `RuleSet` is stage-2 work. We deliberately ship the
9//! signatures first so that:
10//!
11//! 1. the GUI work can start against a frozen type surface,
12//! 2. any downstream code (docs site, dashboards) can begin modelling
13//!    against stable identifiers, and
14//! 3. field additions in later stages are additive rather than
15//!    reshaping.
16//!
17//! Every type here is `#[non_exhaustive]` so adding fields later is not
18//! a breaking change.
19//!
20//! # What these types deliberately hide
21//!
22//! A `RouteCatalogSnapshot` does NOT include execution state (compiled
23//! Rhai AST, open file handles, etc.). It is a photograph of the
24//! declarative routing configuration at one moment — the kind of
25//! information a GUI shows in a "routes" panel, not the live runtime.
26
27use serde::Serialize;
28use serde_json;
29
30pub mod build;
31
32/// A complete snapshot of the server's routing configuration at one moment.
33///
34/// # Why a snapshot rather than a live reference
35///
36/// GUIs navigate, filter, and diff. A borrowed live reference would
37/// require the GUI to hold a lock on the running server's state — not
38/// feasible across an async boundary or an IPC channel. Snapshots are
39/// cheap to clone, cheap to send, and never block the server.
40#[derive(Clone, Debug, Serialize)]
41#[non_exhaustive]
42pub struct RouteCatalogSnapshot {
43    /// Rule sets in the same order as they would be evaluated at request time.
44    pub rule_sets: Vec<RuleSetView>,
45    /// Fallback respond dir (file-based zero-config responder).
46    pub fallback_respond_dir: Option<String>,
47    /// Top-level entries of the fallback respond dir, depth-1 eager.
48    /// `None` when no fallback dir is configured or it doesn't exist
49    /// on disk. Subdirectory contents are not pre-populated; the
50    /// embedder calls `Workspace::list_directory(parent_id)` to expand
51    /// nodes on demand.
52    pub file_tree: Option<FileTreeView>,
53    /// Middleware-script routes, keyed by `service.middlewares` order.
54    pub script_routes: Vec<ScriptRouteView>,
55}
56
57impl RouteCatalogSnapshot {
58    /// Return a snapshot with no content.
59    pub fn empty() -> Self {
60        Self {
61            rule_sets: Vec::new(),
62            fallback_respond_dir: None,
63            file_tree: None,
64            script_routes: Vec::new(),
65        }
66    }
67}
68
69/// GUI-facing view of one rule set.
70#[derive(Clone, Debug, Serialize)]
71#[non_exhaustive]
72pub struct RuleSetView {
73    pub index: usize,
74    pub source_path: String,
75    pub url_path_prefix: Option<String>,
76    pub respond_dir_prefix: Option<String>,
77    /// Per-rule-set strategy override (RFC 025). `None` means "inherit
78    /// from service level". The string is the `snake_case` name
79    /// (e.g. `"round_robin"`, `"first_match"`).
80    pub strategy: Option<String>,
81    pub rules: Vec<RuleView>,
82}
83
84/// GUI-facing view of one rule.
85#[derive(Clone, Debug, Serialize)]
86#[non_exhaustive]
87pub struct RuleView {
88    /// Zero-based index within the parent rule set.
89    pub index: usize,
90    /// Priority for the `Priority` strategy. `None` is treated as 0.
91    /// Higher values win when multiple rules match.
92    pub priority: Option<i32>,
93    /// Structured match conditions.
94    pub when: WhenView,
95    /// The declarative response shape.
96    pub respond: RespondView,
97}
98
99impl RuleView {
100    /// One-line text label for list-row rendering. Backwards-compat
101    /// helper that produces the same shape as 5.0–5.2's
102    /// `when_summary: String` field.
103    pub fn summary(&self) -> String {
104        self.when.summary()
105    }
106}
107
108/// Structured representation of a rule's `when` clause (spec §5.3).
109///
110/// # RFC 004 — Structured WhenView
111///
112/// The boolean `has_header_conditions` / `has_body_conditions` flags
113/// from 5.3.0 are replaced with typed `Vec` fields. The GUI can now
114/// render the full list of conditions in a rule list without a second
115/// query. Use `headers.is_empty()` / `body.is_empty()` wherever the
116/// old boolean flags were checked.
117///
118/// # Why each field is `Option`
119///
120/// A rule with `when.request.url_path = "/api"` and no other clauses
121/// matches every request whose URL is `/api`, regardless of method or
122/// headers. Carrying explicit `None`s for unset fields keeps the
123/// distinction between "not constrained" and "constrained to empty".
124#[derive(Clone, Debug, Default, Serialize)]
125#[non_exhaustive]
126pub struct WhenView {
127    /// URL-path predicate. `None` when the rule has no URL-path constraint.
128    pub url_path: Option<UrlPathView>,
129    /// HTTP method — uppercase string like `"GET"`.
130    pub method: Option<String>,
131    /// Structured header conditions (RFC 004). Empty when none.
132    pub headers: Vec<HeaderConditionView>,
133    /// Structured body conditions (RFC 004). Empty when none.
134    pub body: Vec<BodyConditionView>,
135}
136
137impl WhenView {
138    /// Compact human-readable summary.
139    pub fn summary(&self) -> String {
140        let mut parts: Vec<String> = Vec::new();
141        if let Some(method) = self.method.as_deref() {
142            parts.push(method.to_owned());
143        }
144        if let Some(url) = self.url_path.as_ref() {
145            parts.push(url.summary());
146        }
147        if !self.headers.is_empty() {
148            parts.push(format!("+headers({})", self.headers.len()));
149        }
150        if !self.body.is_empty() {
151            parts.push(format!("+body({})", self.body.len()));
152        }
153        if parts.is_empty() {
154            "(matches everything)".to_owned()
155        } else {
156            parts.join(" ")
157        }
158    }
159}
160
161/// One header condition in a `WhenView`.
162///
163/// # RFC 016 — per-condition identity
164///
165/// A `NodeId` is **not** stored in this routing-crate type; the
166/// routing crate doesn't depend on `apimock-config` or its `NodeId`
167/// type. Instead, the config crate's snapshot layer wraps each view
168/// in a `ConditionWithId` that pairs the routing view with a `NodeId`.
169/// GUI code that needs to issue granular edit commands should use
170/// those wrapped types via the snapshot API.
171#[derive(Clone, Debug, Serialize)]
172#[non_exhaustive]
173pub struct HeaderConditionView {
174    /// Header name as stored (lower-cased at parse time).
175    pub name: String,
176    /// Operator in `snake_case` TOML form, e.g. `"equal"`, `"contains"`.
177    pub op: String,
178    /// Configured value. `None` when the operator implies no value.
179    pub value: Option<String>,
180}
181
182/// One body condition in a `WhenView`.
183#[derive(Clone, Debug, Serialize)]
184#[non_exhaustive]
185pub struct BodyConditionView {
186    /// Body kind — currently always `"json"`.
187    pub kind: String,
188    /// Dotted path into the JSON body.
189    pub path: String,
190    /// Operator in `snake_case` form.
191    pub op: String,
192    /// Configured value as a JSON value.
193    pub value: serde_json::Value,
194}
195
196/// URL-path predicate detail.
197#[derive(Clone, Debug, Serialize)]
198#[non_exhaustive]
199pub struct UrlPathView {
200    /// The path string from the rule, e.g. `"/api/v1/users"`.
201    pub value: String,
202    /// Matching operator name in lowercase TOML form, e.g.
203    /// `"equals"` or `"starts_with"`.
204    pub op: String,
205}
206
207impl UrlPathView {
208    pub fn summary(&self) -> String {
209        format!("{} {}", self.op, self.value)
210    }
211}
212
213/// GUI-facing view of one response shape.
214#[derive(Clone, Debug, Serialize)]
215#[non_exhaustive]
216pub enum RespondView {
217    /// Serve a file. The path is resolved against the rule set's
218    /// `respond_dir_prefix` at request time.
219    File {
220        path: String,
221        csv_records_key: Option<String>,
222    },
223    /// Return a literal text body. `status` is the response code to use
224    /// (defaults to 200 when absent).
225    Text { text: String, status: Option<u16> },
226    /// Return an empty body with just this status code.
227    Status { code: u16 },
228}
229
230/// Shown to the user when they ask "what rule would match *this* request?".
231///
232/// # Why we carry both the match and the non-matches
233///
234/// A GUI debugger doesn't just answer "which rule matched" — it answers
235/// "why didn't the rule I expected match?". Surfacing the mismatches
236/// lets the UI highlight the first failing predicate on each rule.
237#[derive(Clone, Debug, Serialize)]
238#[non_exhaustive]
239pub struct RouteMatchView {
240    /// Matching rule, if any. `None` means the request would fall through
241    /// to the dynamic-route fallback.
242    pub matched: Option<MatchedRule>,
243    /// Every rule the matcher considered before deciding, with the
244    /// reason it was skipped. Order matches evaluation order.
245    pub considered: Vec<MatchConsidered>,
246}
247
248#[derive(Clone, Debug, Serialize)]
249#[non_exhaustive]
250pub struct MatchedRule {
251    pub rule_set_index: usize,
252    pub rule_index: usize,
253}
254
255#[derive(Clone, Debug, Serialize)]
256#[non_exhaustive]
257pub struct MatchConsidered {
258    pub rule_set_index: usize,
259    pub rule_index: usize,
260    /// Free-form text describing why this rule was skipped
261    /// (e.g. `"url_path mismatch"`, `"header 'authorization' missing"`).
262    pub reason: String,
263}
264
265/// Summary of every validation issue found in a [`RouteCatalogSnapshot`].
266///
267/// `ok` iff `issues` is empty; a GUI can render `ok = true` as a green
268/// banner and iterate `issues` otherwise.
269#[derive(Clone, Debug, Serialize)]
270#[non_exhaustive]
271pub struct RouteValidation {
272    pub ok: bool,
273    pub issues: Vec<RouteValidationIssue>,
274}
275
276impl RouteValidation {
277    pub fn ok() -> Self {
278        Self {
279            ok: true,
280            issues: Vec::new(),
281        }
282    }
283}
284
285#[derive(Clone, Debug, Serialize)]
286#[non_exhaustive]
287pub struct RouteValidationIssue {
288    /// Which rule-set this issue came from.
289    pub rule_set_index: usize,
290    /// Which rule within the set, if the issue is rule-scoped.
291    pub rule_index: Option<usize>,
292    /// Severity as the GUI should render it.
293    pub severity: ValidationSeverity,
294    /// Human-readable description.
295    pub message: String,
296}
297
298#[derive(Clone, Copy, Debug, Serialize)]
299pub enum ValidationSeverity {
300    Error,
301    Warning,
302}
303
304// ---------------------------------------------------------------------
305// File-tree view (spec §5.5)
306// ---------------------------------------------------------------------
307
308/// Top-level view of the fallback respond directory, depth-1 eager.
309///
310/// # Why depth-1 and not full recursion
311///
312/// Fallback dirs in real projects can hold thousands of files; full
313/// recursive enumeration would make `snapshot()` expensive. The
314/// `Workspace` provides a separate `list_directory(parent_id)` API
315/// the GUI calls when a user clicks to expand a subdirectory.
316#[derive(Clone, Debug, Serialize)]
317#[non_exhaustive]
318pub struct FileTreeView {
319    /// Absolute path to the fallback respond directory.
320    pub root_path: String,
321    /// Direct children of `root_path`. Subdirectories carry no
322    /// children (`children: None`) — the embedder loads them on demand.
323    pub entries: Vec<FileNodeView>,
324}
325
326#[derive(Clone, Debug, Serialize)]
327#[non_exhaustive]
328pub struct FileNodeView {
329    /// Display name (just the last path component, e.g. `"users.json"`).
330    pub name: String,
331    /// Absolute path on disk.
332    pub path: String,
333    /// What kind of filesystem node this is.
334    pub kind: FileNodeKind,
335    /// For files only — the URL path that would serve this file under
336    /// the dyn-route fallback (e.g. `"/users"` for `users.json`).
337    /// `None` for directories.
338    pub route_hint: Option<String>,
339    /// `Some(empty)` for an unexpanded subdirectory, populated when
340    /// the embedder calls `list_directory` to expand. Always `None`
341    /// for files.
342    pub children: Option<Vec<FileNodeView>>,
343}
344
345#[derive(Clone, Copy, Debug, Serialize)]
346pub enum FileNodeKind {
347    File,
348    Directory,
349}
350
351// ---------------------------------------------------------------------
352// Script-route view (spec §5)
353// ---------------------------------------------------------------------
354
355/// Minimal display info for a Rhai middleware script route.
356///
357/// # Why fields are intentionally minimal
358///
359/// A Rhai middleware can run arbitrary logic to decide whether to
360/// match. Static analysis of "what URLs does this script handle" isn't
361/// feasible without parsing Rhai (and would be unreliable even then).
362/// The view reports only what we *do* know statically — file path and
363/// display label — and leaves any deeper inspection to a hypothetical
364/// future editor feature.
365#[derive(Clone, Debug, Serialize)]
366#[non_exhaustive]
367pub struct ScriptRouteView {
368    /// Index within `service.middlewares_file_paths`.
369    pub index: usize,
370    /// Source file path as recorded in `service.middlewares`.
371    pub source_file: String,
372    /// Human-readable label (typically the file's basename).
373    pub display_name: String,
374}