use std::io::Write;
use std::net::{TcpListener, TcpStream};
use std::time::Duration;
use crate::core::webhook_http::{json_response, read_request, ReadOutcome};
use super::{registry, render_result};
const MAX_BODY_BYTES: usize = 256 * 1024;
const READ_DEADLINE: Duration = Duration::from_secs(15);
pub struct HttpConfig {
pub bind: String,
pub port: u16,
}
pub fn serve(cfg: HttpConfig) -> Result<(), String> {
let addr = format!("{}:{}", cfg.bind, cfg.port);
let listener = TcpListener::bind(&addr).map_err(|e| format!("cannot bind {addr}: {e}"))?;
if cfg.bind != "127.0.0.1" && cfg.bind != "localhost" {
eprintln!(
"warning: serving the verb surface on {} — it has NO authentication. \
Every forjar verb is read-only, so this exposes configuration, not control.",
cfg.bind
);
}
eprintln!("forjar verb surface listening on http://{addr}");
for stream in listener.incoming() {
match stream {
Ok(s) => handle_connection(s),
Err(e) => eprintln!("accept failed: {e}"),
}
}
Ok(())
}
fn handle_connection(mut stream: TcpStream) {
let _ = stream.set_read_timeout(Some(READ_DEADLINE));
let (status, body) = match read_request(&mut stream, MAX_BODY_BYTES, READ_DEADLINE) {
ReadOutcome::Complete {
method, path, body, ..
} => route(&method, &path, &body),
ReadOutcome::Empty => return,
ReadOutcome::Rejected { status, code } => {
(status, serde_json::json!({ "error": code }).to_string())
}
};
let _ = stream.write_all(&json_response(status, &body));
let _ = stream.flush();
}
pub fn route(method: &str, path: &str, body: &[u8]) -> (u16, String) {
let path = path.split('?').next().unwrap_or(path);
match (method, path) {
("GET", "/healthz") => (200, serde_json::json!({ "status": "ok" }).to_string()),
("GET", "/v1/verbs") => (200, render_result(&surface())),
_ => match path.strip_prefix("/v1/verbs/") {
Some(rest) => verb_route(method, rest, body),
None => error(404, "not_found"),
},
}
}
fn verb_route(method: &str, rest: &str, body: &[u8]) -> (u16, String) {
if let Some(name) = rest.strip_suffix("/schema") {
return match (method, registry::find(name)) {
("GET", Some(v)) => (
200,
render_result(&serde_json::json!({
"name": v.name,
"input_schema": (v.input_schema)(),
"output_schema": (v.output_schema)(),
})),
),
(_, None) => error(404, "unknown_verb"),
_ => error(405, "method_not_allowed"),
};
}
let Some(verb) = registry::find(rest) else {
return error(404, "unknown_verb");
};
if method != "POST" {
return error(405, "method_not_allowed");
}
let raw = if body.is_empty() {
b"{}".as_slice()
} else {
body
};
let params: serde_json::Value = match serde_json::from_slice(raw) {
Ok(v) => v,
Err(_) => return error(400, "body_is_not_json"),
};
match (verb.invoke)(params) {
Ok(out) => (200, render_result(&out)),
Err(e) if e.starts_with("invalid params") => (
400,
serde_json::json!({ "error": "invalid_params" }).to_string(),
),
Err(_) => (
500,
serde_json::json!({ "error": "verb_failed" }).to_string(),
),
}
}
fn surface() -> serde_json::Value {
let rows: Vec<_> = registry::verbs()
.iter()
.map(|v| {
serde_json::json!({
"name": v.name,
"mcp_name": v.mcp_name(),
"description": v.description,
"read_only": v.effects.read_only(),
"timeout_ms": v.timeout_ms,
})
})
.collect();
serde_json::json!({ "verbs": rows })
}
fn error(status: u16, code: &'static str) -> (u16, String) {
(status, serde_json::json!({ "error": code }).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_verb_is_404_and_does_not_echo_input() {
let (status, body) = route("POST", "/v1/verbs/<script>alert(1)</script>", b"{}");
assert_eq!(status, 404);
assert!(!body.contains("script"), "the error echoes input: {body}");
}
#[test]
fn get_on_an_invoke_endpoint_is_405() {
assert_eq!(route("GET", "/v1/verbs/validate", b"").0, 405);
}
#[test]
fn unknown_verb_beats_method_check() {
assert_eq!(route("GET", "/v1/verbs/nope", b"").0, 404);
}
#[test]
fn a_body_that_is_not_json_is_400() {
assert_eq!(route("POST", "/v1/verbs/validate", b"not json").0, 400);
}
#[test]
fn invalid_params_are_400_not_500() {
let (status, _) = route("POST", "/v1/verbs/validate", br#"{"wrong":1}"#);
assert_eq!(status, 400, "a params rejection is the client's error");
}
#[test]
fn the_surface_matches_the_registry() {
let (status, body) = route("GET", "/v1/verbs", b"");
assert_eq!(status, 200);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let names: Vec<&str> = v["verbs"]
.as_array()
.unwrap()
.iter()
.map(|r| r["name"].as_str().unwrap())
.collect();
let expected: Vec<&str> = registry::verbs().iter().map(|v| v.name).collect();
assert_eq!(names, expected);
}
#[test]
fn a_query_string_does_not_break_routing() {
assert_eq!(route("GET", "/v1/verbs?limit=1", b"").0, 200);
}
}