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;
28
29pub mod build;
30
31/// A complete snapshot of the server's routing configuration at one moment.
32///
33/// # Why a snapshot rather than a live reference
34///
35/// GUIs navigate, filter, and diff. A borrowed live reference would
36/// require the GUI to hold a lock on the running server's state — not
37/// feasible across an async boundary or an IPC channel. Snapshots are
38/// cheap to clone, cheap to send, and never block the server.
39#[derive(Clone, Debug, Serialize)]
40#[non_exhaustive]
41pub struct RouteCatalogSnapshot {
42    /// Rule sets in the same order as they would be evaluated at request time.
43    pub rule_sets: Vec<RuleSetView>,
44    /// Fallback respond dir (file-based zero-config responder).
45    pub fallback_respond_dir: Option<String>,
46    /// Top-level entries of the fallback respond dir, depth-1 eager.
47    /// `None` when no fallback dir is configured or it doesn't exist
48    /// on disk. Subdirectory contents are not pre-populated; the
49    /// embedder calls `Workspace::list_directory(parent_id)` to expand
50    /// nodes on demand.
51    pub file_tree: Option<FileTreeView>,
52    /// Middleware-script routes, keyed by `service.middlewares` order.
53    pub script_routes: Vec<ScriptRouteView>,
54}
55
56impl RouteCatalogSnapshot {
57    /// Return a snapshot with no content.
58    pub fn empty() -> Self {
59        Self {
60            rule_sets: Vec::new(),
61            fallback_respond_dir: None,
62            file_tree: None,
63            script_routes: Vec::new(),
64        }
65    }
66}
67
68/// GUI-facing view of one rule set.
69#[derive(Clone, Debug, Serialize)]
70#[non_exhaustive]
71pub struct RuleSetView {
72    /// Index within the parent [`RouteCatalogSnapshot::rule_sets`] list.
73    /// Identifies the rule set across edit commands.
74    pub index: usize,
75    /// Source file this rule set was loaded from (relative to the project).
76    pub source_path: String,
77    /// Optional URL path prefix shared across every rule in this set.
78    pub url_path_prefix: Option<String>,
79    /// Optional respond-dir prefix shared across every rule in this set.
80    pub respond_dir_prefix: Option<String>,
81    /// The rules, in evaluation order.
82    pub rules: Vec<RuleView>,
83}
84
85/// GUI-facing view of one rule.
86#[derive(Clone, Debug, Serialize)]
87#[non_exhaustive]
88pub struct RuleView {
89    /// Zero-based index within the parent rule set.
90    pub index: usize,
91    /// Structured match conditions. Spec §5.3 — URL / method /
92    /// headers / JSON conditions. Each field is `Option`-typed so a
93    /// rule that only matches on URL doesn't carry stub values in the
94    /// other slots.
95    pub when: WhenView,
96    /// The declarative response shape.
97    pub respond: RespondView,
98}
99
100impl RuleView {
101    /// One-line text label for list-row rendering. Backwards-compat
102    /// helper that produces the same shape as 5.0–5.2's
103    /// `when_summary: String` field.
104    pub fn summary(&self) -> String {
105        self.when.summary()
106    }
107}
108
109/// Structured representation of a rule's `when` clause (spec §5.3).
110///
111/// # Why each field is `Option`
112///
113/// A rule with `when.request.url_path = "/api"` and no other clauses
114/// matches every request whose URL is `/api`, regardless of method or
115/// headers. Carrying explicit `None`s for unset fields keeps the
116/// distinction between "not constrained" and "constrained to empty"
117/// — the GUI renders the former as a blank field and the latter as
118/// "method = (none)".
119#[derive(Clone, Debug, Default, Serialize)]
120#[non_exhaustive]
121pub struct WhenView {
122    /// URL-path predicate as written in the rule. Carries the matching
123    /// operator (`equals` / `starts_with` / `contains` / `wild_card`
124    /// / `pattern`).
125    pub url_path: Option<UrlPathView>,
126    /// HTTP method — uppercase string like `"GET"` to match the
127    /// underlying `HttpMethod::as_str()` representation.
128    pub method: Option<String>,
129    /// `true` iff the rule restricts on request headers. We don't
130    /// surface the actual header conditions yet (the routing crate's
131    /// `Headers` type isn't publicly inspectable at this stage); the
132    /// boolean tells the GUI whether to render a "headers constraint
133    /// present" badge.
134    pub has_header_conditions: bool,
135    /// `true` iff the rule restricts on request body JSON.
136    pub has_body_conditions: bool,
137}
138
139impl WhenView {
140    /// Compact human-readable summary built from the populated fields
141    /// in priority order (method → path → constraint badges).
142    pub fn summary(&self) -> String {
143        let mut parts: Vec<String> = Vec::new();
144        if let Some(method) = self.method.as_deref() {
145            parts.push(method.to_owned());
146        }
147        if let Some(url) = self.url_path.as_ref() {
148            parts.push(url.summary());
149        }
150        if self.has_header_conditions {
151            parts.push("+headers".to_owned());
152        }
153        if self.has_body_conditions {
154            parts.push("+body".to_owned());
155        }
156        if parts.is_empty() {
157            "(matches everything)".to_owned()
158        } else {
159            parts.join(" ")
160        }
161    }
162}
163
164/// URL-path predicate detail.
165#[derive(Clone, Debug, Serialize)]
166#[non_exhaustive]
167pub struct UrlPathView {
168    /// The path string from the rule, e.g. `"/api/v1/users"`.
169    pub value: String,
170    /// Matching operator name in lowercase TOML form, e.g.
171    /// `"equals"` or `"starts_with"`.
172    pub op: String,
173}
174
175impl UrlPathView {
176    pub fn summary(&self) -> String {
177        format!("{} {}", self.op, self.value)
178    }
179}
180
181/// GUI-facing view of one response shape.
182#[derive(Clone, Debug, Serialize)]
183#[non_exhaustive]
184pub enum RespondView {
185    /// Serve a file. The path is resolved against the rule set's
186    /// `respond_dir_prefix` at request time.
187    File { path: String, csv_records_key: Option<String> },
188    /// Return a literal text body. `status` is the response code to use
189    /// (defaults to 200 when absent).
190    Text { text: String, status: Option<u16> },
191    /// Return an empty body with just this status code.
192    Status { code: u16 },
193}
194
195/// Shown to the user when they ask "what rule would match *this* request?".
196///
197/// # Why we carry both the match and the non-matches
198///
199/// A GUI debugger doesn't just answer "which rule matched" — it answers
200/// "why didn't the rule I expected match?". Surfacing the mismatches
201/// lets the UI highlight the first failing predicate on each rule.
202#[derive(Clone, Debug, Serialize)]
203#[non_exhaustive]
204pub struct RouteMatchView {
205    /// Matching rule, if any. `None` means the request would fall through
206    /// to the dynamic-route fallback.
207    pub matched: Option<MatchedRule>,
208    /// Every rule the matcher considered before deciding, with the
209    /// reason it was skipped. Order matches evaluation order.
210    pub considered: Vec<MatchConsidered>,
211}
212
213#[derive(Clone, Debug, Serialize)]
214#[non_exhaustive]
215pub struct MatchedRule {
216    pub rule_set_index: usize,
217    pub rule_index: usize,
218}
219
220#[derive(Clone, Debug, Serialize)]
221#[non_exhaustive]
222pub struct MatchConsidered {
223    pub rule_set_index: usize,
224    pub rule_index: usize,
225    /// Free-form text describing why this rule was skipped
226    /// (e.g. `"url_path mismatch"`, `"header 'authorization' missing"`).
227    pub reason: String,
228}
229
230/// Summary of every validation issue found in a [`RouteCatalogSnapshot`].
231///
232/// `ok` iff `issues` is empty; a GUI can render `ok = true` as a green
233/// banner and iterate `issues` otherwise.
234#[derive(Clone, Debug, Serialize)]
235#[non_exhaustive]
236pub struct RouteValidation {
237    pub ok: bool,
238    pub issues: Vec<RouteValidationIssue>,
239}
240
241impl RouteValidation {
242    pub fn ok() -> Self {
243        Self {
244            ok: true,
245            issues: Vec::new(),
246        }
247    }
248}
249
250#[derive(Clone, Debug, Serialize)]
251#[non_exhaustive]
252pub struct RouteValidationIssue {
253    /// Which rule-set this issue came from.
254    pub rule_set_index: usize,
255    /// Which rule within the set, if the issue is rule-scoped.
256    pub rule_index: Option<usize>,
257    /// Severity as the GUI should render it.
258    pub severity: ValidationSeverity,
259    /// Human-readable description.
260    pub message: String,
261}
262
263#[derive(Clone, Copy, Debug, Serialize)]
264pub enum ValidationSeverity {
265    Error,
266    Warning,
267}
268
269// ---------------------------------------------------------------------
270// File-tree view (spec §5.5)
271// ---------------------------------------------------------------------
272
273/// Top-level view of the fallback respond directory, depth-1 eager.
274///
275/// # Why depth-1 and not full recursion
276///
277/// Fallback dirs in real projects can hold thousands of files; full
278/// recursive enumeration would make `snapshot()` expensive. The
279/// `Workspace` provides a separate `list_directory(parent_id)` API
280/// the GUI calls when a user clicks to expand a subdirectory.
281#[derive(Clone, Debug, Serialize)]
282#[non_exhaustive]
283pub struct FileTreeView {
284    /// Absolute path to the fallback respond directory.
285    pub root_path: String,
286    /// Direct children of `root_path`. Subdirectories carry no
287    /// children (`children: None`) — the embedder loads them on demand.
288    pub entries: Vec<FileNodeView>,
289}
290
291#[derive(Clone, Debug, Serialize)]
292#[non_exhaustive]
293pub struct FileNodeView {
294    /// Display name (just the last path component, e.g. `"users.json"`).
295    pub name: String,
296    /// Absolute path on disk.
297    pub path: String,
298    /// What kind of filesystem node this is.
299    pub kind: FileNodeKind,
300    /// For files only — the URL path that would serve this file under
301    /// the dyn-route fallback (e.g. `"/users"` for `users.json`).
302    /// `None` for directories.
303    pub route_hint: Option<String>,
304    /// `Some(empty)` for an unexpanded subdirectory, populated when
305    /// the embedder calls `list_directory` to expand. Always `None`
306    /// for files.
307    pub children: Option<Vec<FileNodeView>>,
308}
309
310#[derive(Clone, Copy, Debug, Serialize)]
311pub enum FileNodeKind {
312    File,
313    Directory,
314}
315
316// ---------------------------------------------------------------------
317// Script-route view (spec §5)
318// ---------------------------------------------------------------------
319
320/// Minimal display info for a Rhai middleware script route.
321///
322/// # Why fields are intentionally minimal
323///
324/// A Rhai middleware can run arbitrary logic to decide whether to
325/// match. Static analysis of "what URLs does this script handle" isn't
326/// feasible without parsing Rhai (and would be unreliable even then).
327/// The view reports only what we *do* know statically — file path and
328/// display label — and leaves any deeper inspection to a hypothetical
329/// future editor feature.
330#[derive(Clone, Debug, Serialize)]
331#[non_exhaustive]
332pub struct ScriptRouteView {
333    /// Index within `service.middlewares_file_paths`.
334    pub index: usize,
335    /// Source file path as recorded in `service.middlewares`.
336    pub source_file: String,
337    /// Human-readable label (typically the file's basename).
338    pub display_name: String,
339}