///|
/// Transport-neutral HTTP request parsed from a server or test fixture.
pub(all) struct HttpRequest {
verb : String
path : String
version : String
headers : Map[String, String]
body : String
} derive(Eq, Debug)
///|
pub(all) enum HttpParseResult {
Parsed(HttpRequest)
InvalidHttp(String)
} derive(Eq, Debug)
///|
/// HTTP response returned by a WebHook endpoint.
pub(all) struct HttpResponse {
status : Int
reason : String
headers : Map[String, String]
body : String
} derive(Eq, Debug)
///|
/// Stateful endpoint joining HTTP parsing, HMAC verification, JSON parsing,
/// middleware, deduplication, and routing.
pub struct WebhookEndpoint {
path : String
adapter : ProviderAdapter
secret : String
router : HookRouter
middlewares : Array[HookMiddleware]
deduper : InMemoryDeduper
max_body_bytes : Int
}
///|
pub fn HttpResponse::HttpResponse(
status : Int,
reason : StringView,
body : StringView,
) -> HttpResponse {
let owned_body = body.to_owned()
{
status,
reason: reason.to_owned(),
headers: Map([
("content-type", "text/plain; charset=utf-8"),
("content-length", @utf8.encode(owned_body).length().to_string()),
]),
body: owned_body,
}
}
///|
/// Serialize a response for a TCP/HTTP server binding.
pub fn HttpResponse::render(self : HttpResponse) -> String {
let builder = StringBuilder::new()
builder.write_string("HTTP/1.1 ")
builder.write_string(self.status.to_string())
builder.write_string(" ")
builder.write_string(self.reason)
builder.write_string("\r\n")
for name, value in self.headers {
builder.write_string(name)
builder.write_string(": ")
builder.write_string(value)
builder.write_string("\r\n")
}
builder.write_string("\r\n")
builder.write_string(self.body)
builder.to_string()
}
///|
/// Parse an HTTP/1.x request and normalize header names to lowercase.
pub fn parse_http_request(raw : String) -> HttpParseResult {
let boundary = match raw.find("\r\n\r\n") {
Some(offset) => offset
None => return InvalidHttp("missing HTTP header terminator")
}
let head = raw.view(start_offset=0, end_offset=boundary).to_owned()
let body = raw.view(start_offset=boundary + 4).to_owned()
let lines = head.split("\r\n").to_array()
if lines.length() == 0 {
return InvalidHttp("missing request line")
}
let request_line = lines[0].to_owned().split(" ").to_array()
if request_line.length() != 3 {
return InvalidHttp("malformed request line")
}
let verb = request_line[0].to_owned()
let path = request_line[1].to_owned()
let version = request_line[2].to_owned()
if !version.has_prefix("HTTP/1.") {
return InvalidHttp("unsupported HTTP version")
}
let headers : Map[String, String] = Map([])
for index in 1.. offset
None => return InvalidHttp("malformed HTTP header")
}
if colon == 0 {
return InvalidHttp("empty HTTP header name")
}
let name = line
.view(start_offset=0, end_offset=colon)
.trim()
.to_owned()
.to_lower()
let value = line.view(start_offset=colon + 1).trim().to_owned()
headers[name] = value
}
Parsed({ verb, path, version, headers, body })
}
///|
/// Create a GitHub-compatible HMAC-SHA256 WebHook endpoint.
pub fn new_github_endpoint(
path : StringView,
secret : String,
router : HookRouter,
) -> WebhookEndpoint {
{
path: path.to_owned(),
adapter: github_adapter(),
secret,
router,
middlewares: [HookMiddleware("require-json", require_json_middleware())],
deduper: new_in_memory_deduper(),
max_body_bytes: 1024 * 1024,
}
}
///|
pub fn WebhookEndpoint::use_middleware(
self : WebhookEndpoint,
name : StringView,
callback : MiddlewareFn,
) -> WebhookEndpoint {
let middlewares : Array[HookMiddleware] = []
for middleware in self.middlewares {
middlewares.push(middleware)
}
middlewares.push(HookMiddleware(name, callback))
{ ..self, middlewares, }
}
///|
pub fn WebhookEndpoint::with_max_body_bytes(
self : WebhookEndpoint,
max_body_bytes : Int,
) -> WebhookEndpoint {
{ ..self, max_body_bytes, }
}
///|
fn endpoint_response(result : HookResult) -> HttpResponse {
match result.status {
Accepted => HttpResponse(202, "Accepted", result.message)
Ignored => HttpResponse(200, "OK", result.message)
Retried => HttpResponse(503, "Service Unavailable", result.message)
Failed => HttpResponse(422, "Unprocessable Entity", result.message)
}
}
///|
/// Handle a structured HTTP request supplied by any MoonBit server runtime.
/// Signature verification happens before payload parsing and routing.
pub fn WebhookEndpoint::handle_http(
self : WebhookEndpoint,
http : HttpRequest,
) -> (WebhookEndpoint, HttpResponse) {
if http.verb != "POST" {
return (
self,
HttpResponse(405, "Method Not Allowed", "WebHook endpoint requires POST"),
)
}
if http.path != self.path {
return (
self,
HttpResponse(404, "Not Found", "WebHook endpoint path does not match"),
)
}
if http.body.length() > self.max_body_bytes {
return (
self,
HttpResponse(
413, "Payload Too Large", "WebHook body exceeds configured limit",
),
)
}
let request = self.adapter.parse_request(http.headers, http.body)
let verification = verify_github_signature(request, self.secret)
if verification.status != Verified {
return (self, HttpResponse(401, "Unauthorized", verification.reason))
}
let event = enrich_provider_event(request)
if !self.deduper.should_process(event) {
return (
self,
HttpResponse(200, "OK", "duplicate delivery " + event.idempotency_key),
)
}
let mut pipeline = new_hook_pipeline((event, _) => self.router.dispatch(event))
for middleware in self.middlewares {
pipeline = pipeline.use_middleware(middleware.name, middleware.callback)
}
let outcome = pipeline.run(event)
let next = if outcome.result.status == Accepted {
{ ..self, deduper: self.deduper.remember_event(outcome.event, "processed") }
} else {
self
}
(next, endpoint_response(outcome.result))
}
///|
/// Parse and handle one raw HTTP/1.x request.
pub fn WebhookEndpoint::handle_raw(
self : WebhookEndpoint,
raw : String,
) -> (WebhookEndpoint, HttpResponse) {
match parse_http_request(raw) {
Parsed(request) => self.handle_http(request)
InvalidHttp(reason) => (self, HttpResponse(400, "Bad Request", reason))
}
}