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    /// Index within the parent [`RouteCatalogSnapshot::rule_sets`] list.
74    /// Identifies the rule set across edit commands.
75    pub index: usize,
76    /// Source file this rule set was loaded from (relative to the project).
77    pub source_path: String,
78    /// Optional URL path prefix shared across every rule in this set.
79    pub url_path_prefix: Option<String>,
80    /// Optional respond-dir prefix shared across every rule in this set.
81    pub respond_dir_prefix: Option<String>,
82    /// The rules, in evaluation order.
83    pub rules: Vec<RuleView>,
84}
85
86/// GUI-facing view of one rule.
87#[derive(Clone, Debug, Serialize)]
88#[non_exhaustive]
89pub struct RuleView {
90    /// Zero-based index within the parent rule set.
91    pub index: usize,
92    /// Structured match conditions. Spec §5.3 — URL / method /
93    /// headers / JSON conditions. Each field is `Option`-typed so a
94    /// rule that only matches on URL doesn't carry stub values in the
95    /// other slots.
96    pub when: WhenView,
97    /// The declarative response shape.
98    pub respond: RespondView,
99}
100
101impl RuleView {
102    /// One-line text label for list-row rendering. Backwards-compat
103    /// helper that produces the same shape as 5.0–5.2's
104    /// `when_summary: String` field.
105    pub fn summary(&self) -> String {
106        self.when.summary()
107    }
108}
109
110/// Structured representation of a rule's `when` clause (spec §5.3).
111///
112/// # RFC 004 — Structured WhenView
113///
114/// The boolean `has_header_conditions` / `has_body_conditions` flags
115/// from 5.3.0 are replaced with typed `Vec` fields. The GUI can now
116/// render the full list of conditions in a rule list without a second
117/// query. Use `headers.is_empty()` / `body.is_empty()` wherever the
118/// old boolean flags were checked.
119///
120/// # Why each field is `Option`
121///
122/// A rule with `when.request.url_path = "/api"` and no other clauses
123/// matches every request whose URL is `/api`, regardless of method or
124/// headers. Carrying explicit `None`s for unset fields keeps the
125/// distinction between "not constrained" and "constrained to empty".
126#[derive(Clone, Debug, Default, Serialize)]
127#[non_exhaustive]
128pub struct WhenView {
129    /// URL-path predicate. `None` when the rule has no URL-path constraint.
130    pub url_path: Option<UrlPathView>,
131    /// HTTP method — uppercase string like `"GET"`.
132    pub method: Option<String>,
133    /// Structured header conditions (RFC 004). Empty when none.
134    pub headers: Vec<HeaderConditionView>,
135    /// Structured body conditions (RFC 004). Empty when none.
136    pub body: Vec<BodyConditionView>,
137}
138
139impl WhenView {
140    /// Compact human-readable summary.
141    pub fn summary(&self) -> String {
142        let mut parts: Vec<String> = Vec::new();
143        if let Some(method) = self.method.as_deref() {
144            parts.push(method.to_owned());
145        }
146        if let Some(url) = self.url_path.as_ref() {
147            parts.push(url.summary());
148        }
149        if !self.headers.is_empty() {
150            parts.push(format!("+headers({})", self.headers.len()));
151        }
152        if !self.body.is_empty() {
153            parts.push(format!("+body({})", self.body.len()));
154        }
155        if parts.is_empty() {
156            "(matches everything)".to_owned()
157        } else {
158            parts.join(" ")
159        }
160    }
161}
162
163/// One header condition in a `WhenView`.
164#[derive(Clone, Debug, Serialize)]
165#[non_exhaustive]
166pub struct HeaderConditionView {
167    /// Header name as written in the rule (display-case preserved;
168    /// matching is case-insensitive).
169    pub name: String,
170    /// Operator in `snake_case` TOML form, e.g. `"equal"`, `"contains"`.
171    pub op: String,
172    /// Configured value. `None` when `op` is `"exists"` or `"absent"`.
173    pub value: Option<String>,
174}
175
176/// One body condition in a `WhenView`.
177#[derive(Clone, Debug, Serialize)]
178#[non_exhaustive]
179pub struct BodyConditionView {
180    /// Body kind — currently always `"json"`.
181    pub kind: String,
182    /// Dotted path into the JSON body.
183    pub path: String,
184    /// Operator in `snake_case` form.
185    pub op: String,
186    /// Configured value as a JSON value.
187    pub value: serde_json::Value,
188}
189
190/// URL-path predicate detail.
191#[derive(Clone, Debug, Serialize)]
192#[non_exhaustive]
193pub struct UrlPathView {
194    /// The path string from the rule, e.g. `"/api/v1/users"`.
195    pub value: String,
196    /// Matching operator name in lowercase TOML form, e.g.
197    /// `"equals"` or `"starts_with"`.
198    pub op: String,
199}
200
201impl UrlPathView {
202    pub fn summary(&self) -> String {
203        format!("{} {}", self.op, self.value)
204    }
205}
206
207/// GUI-facing view of one response shape.
208#[derive(Clone, Debug, Serialize)]
209#[non_exhaustive]
210pub enum RespondView {
211    /// Serve a file. The path is resolved against the rule set's
212    /// `respond_dir_prefix` at request time.
213    File { path: String, csv_records_key: Option<String> },
214    /// Return a literal text body. `status` is the response code to use
215    /// (defaults to 200 when absent).
216    Text { text: String, status: Option<u16> },
217    /// Return an empty body with just this status code.
218    Status { code: u16 },
219}
220
221/// Shown to the user when they ask "what rule would match *this* request?".
222///
223/// # Why we carry both the match and the non-matches
224///
225/// A GUI debugger doesn't just answer "which rule matched" — it answers
226/// "why didn't the rule I expected match?". Surfacing the mismatches
227/// lets the UI highlight the first failing predicate on each rule.
228#[derive(Clone, Debug, Serialize)]
229#[non_exhaustive]
230pub struct RouteMatchView {
231    /// Matching rule, if any. `None` means the request would fall through
232    /// to the dynamic-route fallback.
233    pub matched: Option<MatchedRule>,
234    /// Every rule the matcher considered before deciding, with the
235    /// reason it was skipped. Order matches evaluation order.
236    pub considered: Vec<MatchConsidered>,
237}
238
239#[derive(Clone, Debug, Serialize)]
240#[non_exhaustive]
241pub struct MatchedRule {
242    pub rule_set_index: usize,
243    pub rule_index: usize,
244}
245
246#[derive(Clone, Debug, Serialize)]
247#[non_exhaustive]
248pub struct MatchConsidered {
249    pub rule_set_index: usize,
250    pub rule_index: usize,
251    /// Free-form text describing why this rule was skipped
252    /// (e.g. `"url_path mismatch"`, `"header 'authorization' missing"`).
253    pub reason: String,
254}
255
256/// Summary of every validation issue found in a [`RouteCatalogSnapshot`].
257///
258/// `ok` iff `issues` is empty; a GUI can render `ok = true` as a green
259/// banner and iterate `issues` otherwise.
260#[derive(Clone, Debug, Serialize)]
261#[non_exhaustive]
262pub struct RouteValidation {
263    pub ok: bool,
264    pub issues: Vec<RouteValidationIssue>,
265}
266
267impl RouteValidation {
268    pub fn ok() -> Self {
269        Self {
270            ok: true,
271            issues: Vec::new(),
272        }
273    }
274}
275
276#[derive(Clone, Debug, Serialize)]
277#[non_exhaustive]
278pub struct RouteValidationIssue {
279    /// Which rule-set this issue came from.
280    pub rule_set_index: usize,
281    /// Which rule within the set, if the issue is rule-scoped.
282    pub rule_index: Option<usize>,
283    /// Severity as the GUI should render it.
284    pub severity: ValidationSeverity,
285    /// Human-readable description.
286    pub message: String,
287}
288
289#[derive(Clone, Copy, Debug, Serialize)]
290pub enum ValidationSeverity {
291    Error,
292    Warning,
293}
294
295// ---------------------------------------------------------------------
296// File-tree view (spec §5.5)
297// ---------------------------------------------------------------------
298
299/// Top-level view of the fallback respond directory, depth-1 eager.
300///
301/// # Why depth-1 and not full recursion
302///
303/// Fallback dirs in real projects can hold thousands of files; full
304/// recursive enumeration would make `snapshot()` expensive. The
305/// `Workspace` provides a separate `list_directory(parent_id)` API
306/// the GUI calls when a user clicks to expand a subdirectory.
307#[derive(Clone, Debug, Serialize)]
308#[non_exhaustive]
309pub struct FileTreeView {
310    /// Absolute path to the fallback respond directory.
311    pub root_path: String,
312    /// Direct children of `root_path`. Subdirectories carry no
313    /// children (`children: None`) — the embedder loads them on demand.
314    pub entries: Vec<FileNodeView>,
315}
316
317#[derive(Clone, Debug, Serialize)]
318#[non_exhaustive]
319pub struct FileNodeView {
320    /// Display name (just the last path component, e.g. `"users.json"`).
321    pub name: String,
322    /// Absolute path on disk.
323    pub path: String,
324    /// What kind of filesystem node this is.
325    pub kind: FileNodeKind,
326    /// For files only — the URL path that would serve this file under
327    /// the dyn-route fallback (e.g. `"/users"` for `users.json`).
328    /// `None` for directories.
329    pub route_hint: Option<String>,
330    /// `Some(empty)` for an unexpanded subdirectory, populated when
331    /// the embedder calls `list_directory` to expand. Always `None`
332    /// for files.
333    pub children: Option<Vec<FileNodeView>>,
334}
335
336#[derive(Clone, Copy, Debug, Serialize)]
337pub enum FileNodeKind {
338    File,
339    Directory,
340}
341
342// ---------------------------------------------------------------------
343// Script-route view (spec §5)
344// ---------------------------------------------------------------------
345
346/// Minimal display info for a Rhai middleware script route.
347///
348/// # Why fields are intentionally minimal
349///
350/// A Rhai middleware can run arbitrary logic to decide whether to
351/// match. Static analysis of "what URLs does this script handle" isn't
352/// feasible without parsing Rhai (and would be unreliable even then).
353/// The view reports only what we *do* know statically — file path and
354/// display label — and leaves any deeper inspection to a hypothetical
355/// future editor feature.
356#[derive(Clone, Debug, Serialize)]
357#[non_exhaustive]
358pub struct ScriptRouteView {
359    /// Index within `service.middlewares_file_paths`.
360    pub index: usize,
361    /// Source file path as recorded in `service.middlewares`.
362    pub source_file: String,
363    /// Human-readable label (typically the file's basename).
364    pub display_name: String,
365}