add runtime/http.el — HTTP client and server in El

Thin El wrappers over seed primitives that form the public HTTP API for
El programs. Covers GET/POST/DELETE, header-map variants, binary streaming
to file, form-auth, v1/v2 server dispatch, and http_response envelope
construction. Documents two new seed primitives needed: __http_do_map and
__http_do_map_to_file (ElMap-accepting variants to avoid needing map
iteration in El).
This commit is contained in:
Will Anderson
2026-05-03 15:35:04 -05:00
parent 9d0e1f64d4
commit fea830cca2
+171
View File
@@ -0,0 +1,171 @@
// runtime/http.el El HTTP client and server wrappers
//
// Thin El layer over seed primitives. All network I/O is performed by the
// seed; this file provides the public API that El programs import.
//
// Seed primitives consumed:
// __http_do(method, url, body, headers_json, timeout_ms) -> String
// __http_do_to_file(method, url, body, headers_json, out_path) -> Bool
// __http_do_map(method, url, body, headers_map, timeout_ms) -> String
// __http_do_map_to_file(method, url, body, headers_map, out_path) -> Bool
// __http_serve(port, handler_name)
// __http_serve_v2(port, handler_name)
// __http_response(status, headers_json, body) -> String
// __env_get(key) -> String
//
// NOTE FOR SEED AGENT: __http_do_map and __http_do_map_to_file must be added
// to the seed. They are identical to __http_do / __http_do_to_file except
// they accept an ElMap directly for headers instead of a pre-serialised JSON
// string. This avoids needing map iteration in El (which has no for-loop or
// map iterator primitive). The seed implementation maps to headers_from_map()
// in el_runtime.c.
//
// Other builtins used:
// str_eq(a, b) -> Bool
// str_to_int(s) -> Int
// Timeout helper
// el_http_timeout_ms returns the configured HTTP timeout in milliseconds.
// Reads EL_HTTP_TIMEOUT_MS from the environment; defaults to 60000 (60s).
// Returns 60000 if the env var is absent, empty, or non-positive.
fn el_http_timeout_ms() -> Int {
let v: String = __env_get("EL_HTTP_TIMEOUT_MS")
if str_eq(v, "") { return 60000 }
let n: Int = str_to_int(v)
if n <= 0 { return 60000 }
return n
}
// HTTP client simple variants
// http_get performs an HTTP GET request and returns the response body.
// On transport failure the seed returns an error JSON fragment.
fn http_get(url: String) -> String {
return __http_do("GET", url, "", "{}", el_http_timeout_ms())
}
// http_post performs an HTTP POST request with the given body.
// No Content-Type header is set; use http_post_json for JSON payloads.
fn http_post(url: String, body: String) -> String {
return __http_do("POST", url, body, "{}", el_http_timeout_ms())
}
// http_post_json performs an HTTP POST request with Content-Type:
// application/json. body must be a valid JSON string.
fn http_post_json(url: String, body: String) -> String {
return __http_do("POST", url, body, "{\"Content-Type\":\"application/json\"}", el_http_timeout_ms())
}
// http_delete performs an HTTP DELETE request and returns the response body.
fn http_delete(url: String) -> String {
return __http_do("DELETE", url, "", "{}", el_http_timeout_ms())
}
// HTTP client header map variants
//
// These accept a Map<String, String> of request headers. The seed's
// __http_do_map converts the ElMap to a curl_slist internally, matching
// the headers_from_map() logic in el_runtime.c.
// http_get_with_headers performs an HTTP GET with caller-supplied headers.
fn http_get_with_headers(url: String, headers: Map<String, String>) -> String {
return __http_do_map("GET", url, "", headers, el_http_timeout_ms())
}
// http_post_with_headers performs an HTTP POST with caller-supplied headers.
fn http_post_with_headers(url: String, body: String, headers: Map<String, String>) -> String {
return __http_do_map("POST", url, body, headers, el_http_timeout_ms())
}
// http_post_form_auth performs an HTTP POST with
// Content-Type: application/x-www-form-urlencoded and an Authorization
// header built from auth_header (the caller passes the full header value,
// e.g. "Bearer <token>" or "Basic <base64>").
//
// Mirrors http_post_form_auth in el_runtime.c: two headers are injected,
// Content-Type is always set; Authorization is omitted when auth_header is "".
fn http_post_form_auth(url: String, form_body: String, auth_header: String) -> String {
if str_eq(auth_header, "") {
return __http_do("POST", url, form_body, "{\"Content-Type\":\"application/x-www-form-urlencoded\"}", el_http_timeout_ms())
}
return __http_do("POST", url, form_body, "{\"Content-Type\":\"application/x-www-form-urlencoded\",\"Authorization\":\"" + auth_header + "\"}", el_http_timeout_ms())
}
// HTTP client streaming to file
//
// These route the response body directly to a file via the seed, bypassing
// the El string layer. This preserves embedded NUL bytes in binary payloads
// (audio, images, etc.) an El string would truncate at the first NUL.
// Returns true on success, false on any transport or I/O error.
// http_post_to_file performs an HTTP POST and streams the response body to
// output_path. Useful for large or binary response payloads.
fn http_post_to_file(url: String, body: String, headers: Map<String, String>, output_path: String) -> Bool {
return __http_do_map_to_file("POST", url, body, headers, output_path)
}
// http_get_to_file performs an HTTP GET and streams the response body to
// output_path. Useful for large or binary response payloads.
fn http_get_to_file(url: String, headers: Map<String, String>, output_path: String) -> Bool {
return __http_do_map_to_file("GET", url, "", headers, output_path)
}
// HTTP server
//
// El programs call http_set_handler(name) to register which El function
// handles requests, then http_serve(port, name) to start listening.
// The seed resolves handler names via dlsym every El fn compiles to a
// global C symbol with the same name, so self-registration works without
// any El-level registry.
//
// v2 widens the handler signature from
// (method, path, body) -> String
// to
// (method, path, headers_map, body) -> String
// so handlers can inspect incoming headers. Use http_serve_v2 +
// http_set_handler_v2 for v2 handlers.
// http_set_handler registers name as the active v1 request handler.
// The seed resolves the symbol via dlsym at call time; no El-level
// registration is needed. This is a no-op at the El layer.
fn http_set_handler(name: String) {
// no-op: the seed handles handler registration via dlsym
}
// http_serve starts an HTTP/1.1 server on port, dispatching every request
// to handler (a v1 handler: fn(method, path, body) -> String).
// Blocks forever. Accepts both IPv4 and IPv6 (dual-stack).
fn http_serve(port: Int, handler: String) {
__http_serve(port, handler)
}
// http_set_handler_v2 registers name as the active v2 request handler.
// No-op at the El layer; the seed uses dlsym.
fn http_set_handler_v2(name: String) {
// no-op: the seed handles handler registration via dlsym
}
// http_serve_v2 starts an HTTP/1.1 server on port, dispatching every
// request to handler (a v2 handler: fn(method, path, headers, body) ->
// String). Blocks forever. Accepts both IPv4 and IPv6 (dual-stack).
fn http_serve_v2(port: Int, handler: String) {
__http_serve_v2(port, handler)
}
// Response construction
// http_response builds a structured response envelope that the HTTP server
// runtime unpacks into a real HTTP response with the given status code and
// headers. status must be 100599 (defaults to 200 outside that range).
// headers_json must be a JSON object literal (e.g. "{}" or
// "{\"Content-Type\":\"text/html\"}"); body is the response body string.
//
// The envelope format is:
// {"el_http_response":1,"status":<n>,"headers":<obj>,"body":"<escaped>"}
// The runtime detects this prefix and unpacks it; plain string returns from
// handlers are still supported and are sent as HTTP 200 with auto-detected
// Content-Type.
fn http_response(status: Int, headers_json: String, body: String) -> String {
return __http_response(status, headers_json, body)
}