Skip to main content

featherbit/plugins/
ports.rs

1//! Static port declarations for every node type.
2//!
3//! One [`PortSpec`] per plugin type, resolved through
4//! [`crate::plugins::port_spec`] — the single source of truth shared by the
5//! graph compiler (edge validation), the admin catalog (`GET /api/plugins`),
6//! and by extension the UI editor. The [`crate::plugins::Plugin`] trait has no
7//! port method at all: the registry match in `port_spec` IS the declaration,
8//! so a plugin cannot drift from its own ports.
9
10use serde::Serialize;
11
12/// The flavor of an output port, driving validation and UI color.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
14#[serde(rename_all = "lowercase")]
15pub enum PortKind {
16    /// The node completed normally and the request continues.
17    Success,
18    /// The node did its job and chose an alternate route (deny, redirect,
19    /// throttle, preflight). Mandatory wiring, same as success.
20    Outcome,
21    /// The node could not do its job. Optional wiring (fallback chain:
22    /// per-node edge -> policy catch-all -> default 500).
23    Error,
24}
25
26/// One declared output port.
27#[derive(Debug, Clone, Copy, Serialize)]
28pub struct PortDecl {
29    pub name: &'static str,
30    pub kind: PortKind,
31    pub description: &'static str,
32}
33
34/// A node type's full port declaration.
35#[derive(Debug, Clone, Copy, Serialize)]
36pub struct PortSpec {
37    /// Description of the single `in` port; `None` = the node has no input
38    /// (only `listener`).
39    pub input: Option<&'static str>,
40    pub outputs: &'static [PortDecl],
41}
42
43const SUCCESS: PortDecl = PortDecl {
44    name: "success",
45    kind: PortKind::Success,
46    description: "The node completed normally; the request continues.",
47};
48const ERROR: PortDecl = PortDecl {
49    name: "error",
50    kind: PortKind::Error,
51    description: "The node failed (configuration, parse, or infrastructure error).",
52};
53
54/// The default pair every plugin without alternate outcomes uses.
55pub const DEFAULT_SPEC: PortSpec = PortSpec {
56    input: Some("Request context from the previous node."),
57    outputs: &[SUCCESS, ERROR],
58};
59
60/// `listener`: pipeline entry, no input, single exit.
61pub const LISTENER_SPEC: PortSpec = PortSpec {
62    input: None,
63    outputs: &[PortDecl {
64        name: "success",
65        kind: PortKind::Success,
66        description: "Entry into the policy pipeline.",
67    }],
68};
69
70/// `client`: terminal node, the response is sent from here.
71pub const CLIENT_SPEC: PortSpec = PortSpec {
72    input: Some("Final context; the response is sent to the client."),
73    outputs: &[],
74};
75
76/// `cors`: preflight answers short-circuit on their own port.
77pub const CORS_SPEC: PortSpec = PortSpec {
78    input: Some("Request context from the previous node."),
79    outputs: &[
80        SUCCESS,
81        PortDecl {
82            name: "preflight",
83            kind: PortKind::Outcome,
84            description: "OPTIONS preflight answered with a prepared 204; wire to client.",
85        },
86        ERROR,
87    ],
88};
89
90/// `redirect`: prepared 3xx responses exit on their own port.
91pub const REDIRECT_SPEC: PortSpec = PortSpec {
92    input: Some("Request context from the previous node."),
93    outputs: &[
94        SUCCESS,
95        PortDecl {
96            name: "redirect",
97            kind: PortKind::Outcome,
98            description: "A 3xx redirect response is prepared; wire to client.",
99        },
100        ERROR,
101    ],
102};
103
104/// `fault-injection`: injected abort responses exit on their own port.
105pub const FAULT_INJECTION_SPEC: PortSpec = PortSpec {
106    input: Some("Request context from the previous node."),
107    outputs: &[
108        SUCCESS,
109        PortDecl {
110            name: "abort",
111            kind: PortKind::Outcome,
112            description: "An injected fault response is prepared; wire to client.",
113        },
114        ERROR,
115    ],
116};
117
118/// `script`: a script that prepared `ctx.response` and asked to answer with
119/// it (`return ctx, "respond"`) exits on `respond`. It is the same shape as
120/// `abort`/`denied`/`redirect`: a deliberate short-circuit on a declared
121/// port, never inferred from the response the script left behind.
122pub const SCRIPT_SPEC: PortSpec = PortSpec {
123    input: Some("Request context from the previous node."),
124    outputs: &[
125        SUCCESS,
126        PortDecl {
127            name: "respond",
128            kind: PortKind::Outcome,
129            description: "The script prepared ctx.response and returned it with \"respond\"; wire to client (or a custom handler).",
130        },
131        ERROR,
132    ],
133};
134
135/// Credential-auth plugins: deliberate 401/403 rejections exit on `denied`.
136/// Genuine infrastructure failures (consumer store unavailable, LDAP
137/// unreachable, IdP HTTP errors) remain on `error`.
138pub const AUTH_SPEC: PortSpec = PortSpec {
139    input: Some("Request context from the previous node."),
140    outputs: &[
141        SUCCESS,
142        PortDecl {
143            name: "denied",
144            kind: PortKind::Outcome,
145            description: "Authentication or authorization was denied; a 4xx response is prepared. Wire to client (or a custom denial handler).",
146        },
147        ERROR,
148    ],
149};
150
151/// Interactive SSO plugins: denied rejections plus browser redirects.
152pub const INTERACTIVE_AUTH_SPEC: PortSpec = PortSpec {
153    input: Some("Request context from the previous node."),
154    outputs: &[
155        SUCCESS,
156        PortDecl {
157            name: "denied",
158            kind: PortKind::Outcome,
159            description: "Authentication was denied; a 4xx response is prepared. Wire to client.",
160        },
161        PortDecl {
162            name: "redirect",
163            kind: PortKind::Outcome,
164            description: "The browser must move (login/logout/callback 3xx); response is prepared. Wire to client.",
165        },
166        ERROR,
167    ],
168};
169
170/// Restriction and request-shape plugins: a deliberate policy rejection
171/// (IP/UA/referer/consumer/group deny, blocked URI, missing/invalid CSRF
172/// token, oversized body, schema mismatch) exits on `denied`. Same shape as
173/// [`AUTH_SPEC`] but kept as its own const so the description can speak to
174/// policy rejections rather than credentials.
175pub const DENY_SPEC: PortSpec = PortSpec {
176    input: Some("Request context from the previous node."),
177    outputs: &[
178        SUCCESS,
179        PortDecl {
180            name: "denied",
181            kind: PortKind::Outcome,
182            description: "The request was denied by a policy rule; a 4xx response is prepared. Wire to client.",
183        },
184        ERROR,
185    ],
186};
187
188/// Traffic-control plugins (`rate-limit`, `limit-conn`, `limit-count`): a
189/// throttled request exits on `limited`.
190pub const LIMIT_SPEC: PortSpec = PortSpec {
191    input: Some("Request context from the previous node."),
192    outputs: &[
193        SUCCESS,
194        PortDecl {
195            name: "limited",
196            kind: PortKind::Outcome,
197            description:
198                "The request exceeded a traffic limit; a 429 response is prepared. Wire to client.",
199        },
200        ERROR,
201    ],
202};
203
204/// `api-breaker`: the check phase's open-circuit short-circuit exits on
205/// `broken`.
206pub const BREAKER_SPEC: PortSpec = PortSpec {
207    input: Some("Request context from the previous node."),
208    outputs: &[
209        SUCCESS,
210        PortDecl {
211            name: "broken",
212            kind: PortKind::Outcome,
213            description:
214                "The circuit breaker is open; the break response is prepared. Wire to client.",
215        },
216        ERROR,
217    ],
218};
219
220/// `workflow`: a rejecting `return` rule exits on `denied`; an exceeded
221/// `limit-count` rule exits on `limited`.
222pub const WORKFLOW_SPEC: PortSpec = PortSpec {
223    input: Some("Request context from the previous node."),
224    outputs: &[
225        SUCCESS,
226        PortDecl {
227            name: "denied",
228            kind: PortKind::Outcome,
229            description: "A 'return' rule rejected the request; the configured response is prepared. Wire to client.",
230        },
231        PortDecl {
232            name: "limited",
233            kind: PortKind::Outcome,
234            description: "A 'limit-count' rule's quota was exceeded; a rejection response is prepared. Wire to client.",
235        },
236        ERROR,
237    ],
238};
239
240/// `traffic-split`: a request steered to and served by a weighted split
241/// target exits on `routed`. `success` covers both "no rule matched" and
242/// "the default slot was picked" — the request continues to the route's
243/// normal upstream unchanged.
244pub const TRAFFIC_SPLIT_SPEC: PortSpec = PortSpec {
245    input: Some("Request context from the previous node."),
246    outputs: &[
247        SUCCESS,
248        PortDecl {
249            name: "routed",
250            kind: PortKind::Outcome,
251            description:
252                "The request was steered to and served by a weighted split target; wire to client.",
253        },
254        ERROR,
255    ],
256};
257
258/// `condition`: a pure branching waypoint — no `success` port, the request
259/// always leaves on `true` or `false`. Evaluation is lenient (absent
260/// variables compare as empty, a JSONPath over a non-JSON body matches
261/// nothing), so the node itself never errors; `error` stays declared for
262/// compatibility with policies that wired it.
263pub const CONDITION_SPEC: PortSpec = PortSpec {
264    input: Some("Request context from the previous node."),
265    outputs: &[
266        PortDecl {
267            name: "true",
268            kind: PortKind::Outcome,
269            description: "The conditions evaluated to true.",
270        },
271        PortDecl {
272            name: "false",
273            kind: PortKind::Outcome,
274            description: "The conditions evaluated to false.",
275        },
276        ERROR,
277    ],
278};
279
280/// `proxy-cache` (lookup phase): a cache hit exits on `hit`. `success`
281/// covers a miss or a non-cacheable method/bypass — the request continues to
282/// the upstream.
283pub const PROXY_CACHE_SPEC: PortSpec = PortSpec {
284    input: Some("Request context from the previous node."),
285    outputs: &[
286        SUCCESS,
287        PortDecl {
288            name: "hit",
289            kind: PortKind::Outcome,
290            description: "The response was served from cache; wire to client.",
291        },
292        ERROR,
293    ],
294};
295
296/// `store-get`: a key that does not exist is a normal outcome, not an error --
297/// it exits `miss`, which the compiler forces the policy to wire. A store
298/// outage exits `error` instead, so the two stay distinguishable.
299pub const STORE_GET_SPEC: PortSpec = PortSpec {
300    input: Some("Request context from the previous node."),
301    outputs: &[
302        SUCCESS,
303        PortDecl {
304            name: "miss",
305            kind: PortKind::Outcome,
306            description: "The key does not exist; nothing was written to context.message. Wire to whatever should happen on first sight.",
307        },
308        ERROR,
309    ],
310};
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    /// Names no custom port may use. `out` is a YAML alias for `success`.
317    /// (Nothing validates this at runtime — every spec is a static in this
318    /// file, so the test below is the enforcement point.)
319    const RESERVED_PORT_NAMES: &[&str] = &["in", "out", "success", "error"];
320
321    /// Every registered plugin type resolves to a spec, and every custom
322    /// (non-default) output name is lowercase-kebab and non-reserved.
323    #[test]
324    fn test_every_known_type_has_a_valid_spec() {
325        for ty in crate::plugins::KNOWN_PLUGIN_TYPES {
326            let spec =
327                crate::plugins::port_spec(ty).unwrap_or_else(|| panic!("no port spec for '{ty}'"));
328            for p in spec.outputs {
329                if p.name != "success" && p.name != "error" {
330                    assert!(
331                        !RESERVED_PORT_NAMES.contains(&p.name),
332                        "'{ty}' declares reserved port '{}'",
333                        p.name
334                    );
335                    assert!(
336                        p.name.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
337                        "'{ty}' port '{}' is not lowercase-kebab",
338                        p.name
339                    );
340                    assert!(
341                        matches!(p.kind, PortKind::Outcome),
342                        "'{ty}' custom port '{}' must be kind outcome",
343                        p.name
344                    );
345                }
346                assert!(
347                    !p.description.is_empty(),
348                    "'{ty}' port '{}' lacks description",
349                    p.name
350                );
351            }
352        }
353    }
354
355    #[test]
356    fn test_structural_specs() {
357        let l = crate::plugins::port_spec("listener").unwrap();
358        assert!(l.input.is_none());
359        assert_eq!(l.outputs.len(), 1);
360        assert_eq!(l.outputs[0].name, "success");
361
362        let c = crate::plugins::port_spec("client").unwrap();
363        assert!(c.input.is_some());
364        assert!(c.outputs.is_empty());
365
366        assert!(crate::plugins::port_spec("no-such-type").is_none());
367    }
368}