use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::pipeline::model::{
DslFlag, DslFlagKind, LayoutItem, NodeFieldDef, NodeFieldType, SelectOptionDef,
};
use crate::pipeline::{
NodeDefinition, PipelineError,
nodes::{NodeExecutionInput, NodeExecutionOutput, NodeHandler},
};
pub const NODE_KIND: &str = "n.trigger.weberror";
const INPUT_PIN_IN: &str = "in";
const OUTPUT_PIN_OUT: &str = "out";
pub fn definition() -> NodeDefinition {
NodeDefinition {
kind: NODE_KIND.to_string(),
title: "Web Error Trigger".to_string(),
description: "Runs when a request to this project ends in an HTTP error that no pipeline answered — a `/wh/…` path nobody \
registered (404), a refused auth (401/403), a failed node (500). `--code` picks which: `404`, `4xx`, `5xx`, or empty for \
all. The payload is `{ error_code, error_message, original_path, method }` — there is no `body`. End in \
`web.response --template pages/not-found.tsx --status 404` to serve a designed error page; without `--status` the page \
answers 200 and browsers and crawlers treat the error as a success. One pipeline per code range; the most specific wins."
.to_string(),
input_schema: json!({
"type": "object",
"properties": {
"error_code": { "type": "integer", "description": "HTTP error code (e.g. 404)." },
"error_message": { "type": "string", "description": "Standard HTTP reason phrase." },
"original_path": { "type": "string", "description": "Path that triggered the error." },
"method": { "type": "string", "description": "HTTP method of the original request." }
}
}),
output_schema: json!({
"type": "object",
"properties": {
"error_code": { "type": "integer" },
"error_message": { "type": "string" },
"original_path": { "type": "string" },
"method": { "type": "string" }
}
}),
input_pins: vec![INPUT_PIN_IN.to_string()],
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
script_available: false,
script_bridge: None,
config_schema: Default::default(),
dsl_flags: vec![DslFlag {
flag: "--code".to_string(),
config_key: "code".to_string(),
description:
"Error code pattern: exact code (404), range (4xx/5xx), or empty for catch-all."
.to_string(),
kind: DslFlagKind::Scalar,
required: false,
}],
fields: vec![NodeFieldDef {
name: "code".to_string(),
label: "Error Code Pattern".to_string(),
field_type: NodeFieldType::Select,
options: vec![
SelectOptionDef {
value: "404".to_string(),
label: "404 — Not Found".to_string(),
},
SelectOptionDef {
value: "4xx".to_string(),
label: "4xx — Any Client Error".to_string(),
},
SelectOptionDef {
value: "5xx".to_string(),
label: "5xx — Any Server Error".to_string(),
},
SelectOptionDef {
value: "*".to_string(),
label: "* — All errors (catch-all)".to_string(),
},
],
help: Some("Which HTTP error code(s) this pipeline handles.".to_string()),
..Default::default()
}],
layout: vec![LayoutItem::Field("code".to_string())],
ai_tool: Default::default(),
examples: vec![
crate::pipeline::model::NodeExample::dsl("A designed 404", "trigger.weberror --code 404")
.output(serde_json::json!({ "error_code": 404, "error_message": "no pipeline for GET /blog/old-post", "original_path": "/blog/old-post", "method": "GET" }))
.note("Then `| web.response --template pages/not-found.tsx --status 404`."),
],
..Default::default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default, deserialize_with = "code_from_string_or_number")]
pub code: String,
}
fn code_from_string_or_number<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
let value = serde_json::Value::deserialize(d)?;
Ok(match value {
serde_json::Value::String(s) => s,
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Null => String::new(),
other => return Err(serde::de::Error::custom(format!("code must be a string or a number, got {other}"))),
})
}
pub fn match_specificity(code_pattern: &str, error_code: u16) -> Option<u8> {
if code_pattern.is_empty() || code_pattern == "*" {
return Some(0); }
if code_pattern.eq_ignore_ascii_case("4xx") && (400..500).contains(&error_code) {
return Some(1);
}
if code_pattern.eq_ignore_ascii_case("5xx") && (500..600).contains(&error_code) {
return Some(1);
}
if let Ok(exact) = code_pattern.parse::<u16>() {
if exact == error_code {
return Some(2); }
}
None }
pub struct Node {
#[allow(dead_code)]
config: Config,
}
impl Node {
pub fn new(config: Config) -> Self {
Self { config }
}
}
#[async_trait]
impl NodeHandler for Node {
fn kind(&self) -> &'static str {
NODE_KIND
}
fn input_pins(&self) -> &'static [&'static str] {
&[INPUT_PIN_IN]
}
fn output_pins(&self) -> &'static [&'static str] {
&[OUTPUT_PIN_OUT]
}
async fn execute_async(
&self,
input: NodeExecutionInput,
) -> Result<NodeExecutionOutput, PipelineError> {
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: input.payload,
trace: vec!["n.trigger.weberror: passthrough".to_string()],
})
}
}
#[cfg(test)]
mod code_tests {
use super::*;
#[test]
fn code_accepts_the_number_the_dsl_types_it_as() {
let numeric: Config = serde_json::from_value(serde_json::json!({ "code": 404 })).unwrap();
assert_eq!(numeric.code, "404");
let text: Config = serde_json::from_value(serde_json::json!({ "code": "4xx" })).unwrap();
assert_eq!(text.code, "4xx");
let missing: Config = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(missing.code, "");
}
}