Hica HTTP client and server library. The client is built on libcurl; the server on libmicrohttpd, with a FastAPI-style typed router on top.
- libcurl — for the client (ships with macOS,
apt install libcurl4-openssl-devon Debian/Ubuntu) - libmicrohttpd — for the server (
brew install libmicrohttpdon macOS,apt install libmicrohttpd-devon Debian/Ubuntu) - Koka 3.2.3+
- Hica 0.41.6+
hica add http
hica fetchThis records the dependency in hica.hml and downloads the package.
Add the linker and include flags to your project's hica.hml. The client only
needs --cclib=curl; the server additionally needs libmicrohttpd:
@project {
name: "my-app"
version: "0.1.0"
entry: "main.hc"
}
@koka {
include: "./lib/http/src"
flags: "--cclib=curl --cclib=microhttpd --ccincdir=/opt/homebrew/include --cclinkopts=-L/opt/homebrew/lib"
}
Portability note: the
/opt/homebrewpaths locate libmicrohttpd's headers and library on Apple Silicon macOS. On Intel macOS use/usr/local; on Linux libmicrohttpd installs to default search paths, so the extra--ccincdir/--cclinkoptsentries are harmless no-ops and can be omitted. If you only use the client, drop--cclib=microhttpdand the two path flags entirely.
The http module is written in Koka (C FFI), so you import it with extern import:
extern import "http"
import "http_client"extern import passes the Koka module through without compilation.
import http_client brings in the Hica utilities (headers, URL building, auth helpers).
extern import "http"
import "http_client"
fun main() {
// Simple GET request
let resp = http_get("https://httpbin.org/get", timeout=10)
println("Status: " + show(resp.status))
println("OK? " + show(is_ok(resp)))
// Parse response headers
let hdrs = parse_headers(resp.headers)
println("Content-Type: " + unwrap_maybe_or(get_header(hdrs, "Content-Type"), "(none)"))
// JSON POST
let post_resp = json_post("https://httpbin.org/post", "\{\"hello\":\"world\"\}")
println("Post status: " + show(post_resp.status))
// Build URL with query parameters
let url = build_url("https://httpbin.org/get", [
Param { key: "page", value: "1" },
Param { key: "limit", value: "10" }
])
let qresp = http_get(url)
println("Query status: " + show(qresp.status))
}Build and run:
hica build main.hc
./mainThe @koka block in hica.hml handles include paths and compiler flags automatically.
Core HTTP functions powered by libcurl:
| Function | Description |
|---|---|
http_get(url, timeout=0) |
GET request |
http_post(url, body, content_type, timeout) |
POST request |
http_put(url, body, content_type, timeout) |
PUT request |
http_delete(url, timeout) |
DELETE request |
http_patch(url, body, content_type, timeout) |
PATCH request |
http_head(url, timeout) |
HEAD request |
http_request(method, url, body, content_type, headers, timeout) |
General request with custom headers |
is_ok(resp) |
True if status is 2xx |
is_redirect(resp) |
True if status is 3xx |
is_client_error(resp) |
True if status is 4xx |
is_server_error(resp) |
True if status is 5xx |
json_get(url, timeout) |
GET with Accept: application/json |
json_post(url, body, timeout) |
POST with JSON content type |
json_put(url, body, timeout) |
PUT with JSON content type |
json_patch(url, body, timeout) |
PATCH with JSON content type |
form_post(url, body, timeout) |
POST with form-urlencoded content type |
All functions return an http_response with fields: status (int), body (string), headers (string).
Note (Hica callers):
is_okcollides with Hica's built-inResultcombinator of the same name. In.hccode,is_ok(resp)resolves to the built-in (compiled to.is-right) instead of this function — useresp.status >= 200 && resp.status < 300instead.is_redirect,is_client_error, andis_server_errorare unaffected, as are all Koka callers.
Header parsing and lookup:
| Function | Description |
|---|---|
parse_headers(raw) |
Parse raw header string into list<Header> |
get_header(hdrs, name) |
Look up header by name (case-insensitive), returns maybe<string> (None if not found) |
has_header(hdrs, name) |
Check if a header exists |
find_header(raw, name) |
Shortcut — parse + look up in one call, returns maybe<string> |
URL and query string building:
| Function | Description |
|---|---|
build_url(base, params) |
Append query parameters to a URL |
build_query(params) |
Build a query string from list<Param> |
encode_param(p) |
Encode a single Param as key=value |
Param { key, value } |
Query parameter constructor |
Re-exports headers and url, plus auth helpers:
| Function | Description |
|---|---|
with_bearer(token) |
Build Authorization: Bearer <token> header string |
with_basic_auth(credentials) |
Build Authorization: Basic <credentials> header string |
Use with http_request:
let resp = http_request("GET", "https://api.example.com/me", headers=with_bearer(my_token))
The server is built on libmicrohttpd. libmicrohttpd's internal thread handles all socket I/O and holds concurrent connections open; requests are handed to your handler one at a time for sequential processing (the Koka runtime is single-threaded). This is well suited to I/O-bound services and tooling.
Tip:
web.hcis a convenience barrel —import "web"re-exports the router, middleware, static-file, and content-negotiation modules (and the response helpers) so a typical app needs a single import. Addimport "body"too for typed request/response bodies (it is separate because it pulls in the json library).
router.hc provides a FastAPI-style typed router: declare routes with
get/post/put/delete/patch, and the first matching route wins.
Unmatched requests return 404 automatically. If a handler raises an exception
(a bad index, unwrap of an Err, an explicit throw, …), it is caught and
turned into a 500 Internal Server Error — the server stays healthy and keeps
serving other requests.
import "router"
fun handle_get_item(req) : ServerResponse {
let id = path_int(req, "id") // path param /items/{id}
let verbose = query_bool(req, "verbose") // ?verbose=true -> maybe<bool>
json_response("\{\"id\": " + show(id) + "\}")
}
fun handle_create(req) : ServerResponse {
// req body is available as a string; parse it with the json library
json_response(req_body(req))
}
fun main() {
println("Listening on :8080")
serve_routes(8080, [
get("/", (req) => json_response("\{\"hello\": \"world\"\}")),
get("/items/{id}", handle_get_item),
post("/items", handle_create),
delete("/items/{id}", (req) => status_response(204, ""))
])
}| Function | Description |
|---|---|
get(pattern, handler) |
Register a GET route |
post(pattern, handler) |
Register a POST route |
put(pattern, handler) |
Register a PUT route |
delete(pattern, handler) |
Register a DELETE route |
patch(pattern, handler) |
Register a PATCH route |
any(pattern, handler) |
Match any HTTP method (catch-all) |
group(prefix, routes) |
Prefix a list of routes with a shared path |
serve_routes(port, routes) |
Start serving; dispatches to routes, auto-404. Never returns |
Patterns support {name} path parameters, e.g. /items/{id}/tags/{tag}.
Trailing slashes are normalised.
group mounts a set of routes under a shared prefix (like Ktor's
route("/tasks") { ... }). It returns a plain list, so it composes with +
and nests:
serve_routes(8080,
group("/tasks", [
get("/", list_tasks), // GET /tasks
get("/{id}", get_task), // GET /tasks/{id}
post("/", create_task) // POST /tasks
]) +
[ get("/health", health) ]) // GET /healthEach handler receives a request. Read from it with these helpers (all take
req as the first argument):
| Function | Returns | Description |
|---|---|---|
path_str(req, key) |
string |
Path parameter ("" if absent) |
path_int(req, key) |
int |
Path parameter as int (0 if absent/non-numeric) |
path_str_opt(req, key) |
maybe<string> |
Path parameter (None if absent) |
path_int_opt(req, key) |
maybe<int> |
Path parameter as int (None if absent/non-numeric) |
query_str(req, key) |
maybe<string> |
Query parameter |
query_int(req, key) |
maybe<int> |
Query parameter as int |
query_bool(req, key) |
maybe<bool> |
Query parameter (true/1, false/0) |
req_header(req, name) |
maybe<string> |
Request header (case-insensitive) |
bearer_token(req) |
maybe<string> |
Token from Authorization: Bearer ... |
req_method(req) |
string |
Request method (GET, POST, ...) |
req_path(req) |
string |
Request path |
req_body(req) |
string |
Raw request body (parse with the json library) |
form_str(req, key) |
maybe<string> |
Field from a urlencoded form body |
form_int(req, key) |
maybe<int> |
Form field as int |
cookie(req, key) |
maybe<string> |
Value from the Cookie request header |
accepts(req, mime) |
bool |
Does the client's Accept header allow mime? |
| Function | Description |
|---|---|
text_response(body) |
200 with text/plain |
json_response(body) |
200 with application/json |
html_response(body) |
200 with text/html |
not_found_response() |
404 Not Found |
error_response(msg) |
500 with the message |
status_response(status, body) |
Custom status with a plain-text body |
content_response(status, content_type, body) |
Custom status with an explicit Content-Type |
redirect(url) |
302 Found to url |
redirect_permanent(url) |
301 Moved Permanently to url |
accepted(body) |
202 Accepted |
no_content() |
204 No Content |
bad_request(msg) |
400 Bad Request |
unauthorized(msg) |
401 Unauthorized |
forbidden(msg) |
403 Forbidden |
with_header(resp, name, value) |
Append a header to a response |
set_cookie(resp, name, value) |
Add a Set-Cookie header (Path=/) |
middleware.hc adds a Starlette-style middleware chain around the router. A
middleware is a function (req, next) -> route_response:
- call
next(req)to continue the chain (and optionally transform the result) - return a response without calling
nextto short-circuit
Middlewares run in list order — the first is outermost (runs first). Use
serve_routes_mw instead of serve_routes:
import "middleware"
fun main() {
serve_routes_mw(8080, [
get("/", (req) => json_response("\{\"hello\": \"world\"\}")),
get("/me", (req) => json_response("\{\"ok\": true\}"))
], [
logger(), // logs "METHOD /path"
cors("*"), // CORS headers + OPTIONS preflight
require_bearer("secret123") // 401/403 unless a matching Bearer token
])
}| Function | Description |
|---|---|
logger() |
Log an access line "METHOD /path STATUS Nms" after each request (status + latency), flushed so file-redirected logs stay current |
cors(origin) |
Add Access-Control-Allow-Origin to responses; answer OPTIONS preflight with 204 |
require_bearer(token) |
401 if the Authorization header is absent, 403 if the token mismatches, else continue |
A middleware is just a two-argument function returning a route_response. Build
short-circuit responses with the respond_* helpers, or transform a downstream
response with route_add_header:
fun request_timer() {
(req, next) => {
let resp = next(req)
route_add_header(resp, "X-Handled-By", "hica")
}
}| Function | Description |
|---|---|
serve_routes_mw(port, routes, middlewares) |
Serve with a middleware chain wrapped around the router. Never returns |
respond_json(body) |
200 application/json route_response |
respond_text(body) |
200 text/plain route_response |
respond_status(code, body) |
Custom-status text/plain route_response |
route_add_header(resp, name, value) |
Append a header to a route_response |
If a handler raises an exception (a bad list index, an unwrap of an Err, a
division by zero, an explicit throw, ...) the server catches it and returns a
plain 500 Internal Server Error, keeping the event loop healthy. To decide the
response yourself (like Ktor's StatusPages exception<T> { ... }) use the
_with_errors variants and pass an on_error callback. It receives the caught
exception message and returns a ServerResponse:
fun on_error(msg: string) : ServerResponse {
json_status(500, "\{\"error\": \"internal\"\}")
}
fun main() {
serve_routes_with_errors(8080, [
get("/", (req) => json_response("\{\"ok\": true\}")),
get("/boom", (req) => json_response("ok " + show(1 / 0))) // raises
], on_error)
}Unmatched routes still return 404. See examples/server_errors.hc.
| Function | Description |
|---|---|
serve_routes_with_errors(port, routes, on_error) |
Like serve_routes, but a caught exception's message is passed to on_error, which returns the response. Never returns |
serve_routes_mw_with_errors(port, routes, middlewares, on_error) |
serve_routes_mw with a custom error handler. Never returns |
negotiation.hc offers several representations of a resource and lets the
client's Accept header choose — like Ktor's ContentNegotiation /
call.respond(obj). Ranges (type/*, */*) and quality values (;q=0.8) are
honoured, and a more specific range wins over a less specific one, so a browser
is served HTML while an API client (or curl's default */*) is served JSON —
even when JSON is offered first:
import "negotiation"
fun handle_item(req) : ServerResponse {
negotiate(req, [
("application/json", "\{\"name\": \"Widget\"\}"),
("text/html; charset=utf-8", "<h1>Widget</h1>")
])
}negotiate returns a 200 with the chosen Content-Type and a Vary: Accept
header, or 406 Not Acceptable when the client accepts none of the offers. See
examples/server_negotiation.hc.
| Function | Description |
|---|---|
negotiate(req, offers) |
Pick a representation from [(content_type, body), ...] by the Accept header; 406 if none match |
accepts(req, mime) |
true if the client's Accept header allows mime |
accepts_json(req) |
Shorthand for accepts(req, "application/json") |
accepts_html(req) |
Shorthand for accepts(req, "text/html") |
not_acceptable() |
406 Not Acceptable response |
body.hc decodes a JSON request body into your own struct with validation,
returning 422 Unprocessable Entity (as FastAPI does) on malformed or invalid
input. It is an opt-in module, importing it pulls in the json
library, and re-exports the Json type and json_emit so you can build responses too.
import "body"
struct Item { name: string, price: float, in_stock: bool }
// A decoder is `(Json) -> result<Item, string>`: chain field decoders and
// build your struct, or return an error message.
fun decode_item(doc: Json) : result<Item, string> {
match field_str(doc, "name") {
Err(e) => Err(e),
Ok(name) => match field_num(doc, "price") {
Err(e) => Err(e),
Ok(price) => match opt_bool(doc, "in_stock") {
Err(e) => Err(e),
Ok(stock) => Ok(Item { name: name, price: price, in_stock: unwrap_maybe_or(stock, true) })
}
}
}
}
// Encode a struct to a JSON value with the terse constructors.
fun encode_item(it: Item) : Json =>
jobj([
("name", jstr(it.name)),
("price", jnum(it.price)),
("in_stock", jbool(it.in_stock))
])
fun handle_create(req) {
// with_body decodes-or-422s, then hands you the typed value — the round
// trip of Ktor's call.receive<T>() + call.respond(obj).
with_body(req, decode_item, (item) =>
created_json(encode_item(item))) // 201 on success, 422 on a bad body
}Each returns Ok(value) when the field is present and the right type,
Err(message) when it is missing or the wrong type.
| Function | Returns | Description |
|---|---|---|
field_str(doc, key) |
result<string, string> |
Required string field |
field_int(doc, key) |
result<int, string> |
Required integer (rejects non-integral numbers) |
field_num(doc, key) |
result<float, string> |
Required number (integer or float) |
field_bool(doc, key) |
result<bool, string> |
Required boolean field |
Optional variants return Ok(None) when absent, Ok(Some(v)) when present and
correct, and Err when present but the wrong type:
| Function | Returns |
|---|---|
opt_str(doc, key) |
result<maybe<string>, string> |
opt_int(doc, key) |
result<maybe<int>, string> |
opt_bool(doc, key) |
result<maybe<bool>, string> |
| Function | Description |
|---|---|
with_body(req, decoder, handler) |
Decode the body; call handler with the typed value, or return 422 on a bad body. The typed round-trip |
decode_body(req, decoder) |
Parse the request body as JSON, then run decoder. Err on malformed JSON or a decode failure |
unprocessable(msg) |
422 Unprocessable Entity with a {"detail": msg} JSON body |
The mirror of decoding: build a Json value with terse constructors, then send
it. Encode a collection by mapping the encoder over a list and wrapping in an
array — ok_json(jarr(map(items, encode_item))).
| Function | Returns | Description |
|---|---|---|
jstr(s) / jnum(x) / jint(n) / jbool(b) / jnull() |
Json |
Scalar JSON values |
jobj(fields) |
Json |
Object from a list<(string, Json)> |
jarr(items) |
Json |
Array from a list<Json> |
ok_json(j) |
ServerResponse |
200 OK with the encoded JSON body |
created_json(j) |
ServerResponse |
201 Created with the encoded JSON body |
json_response_of(status, j) |
ServerResponse |
Custom status with the encoded JSON body |
multipart.hc (included in the web barrel) parses multipart/form-data
request bodies into a list of Part values. The parsing is done in the C layer
by libmicrohttpd's MHD_PostProcessor, so each part's bytes arrive
incrementally and are fully accumulated before your handler is called.
Each part is binary-safe: Part.bytes is a raw byte string and can hold
arbitrary data including NUL bytes. Individual parts are capped at 64 MiB by
the C layer; uploads beyond that are rejected by MHD before the handler is
invoked.
import "web" // re-exports multipart
// Publish endpoint: accepts a JSON metadata part and a binary tarball part.
fun handle_publish(req) {
match req_part(req, "metadata") {
None => bad_request("missing 'metadata' part"),
Some(meta) => match req_part(req, "tarball") {
None => bad_request("missing 'tarball' part"),
Some(tar) => {
let name = path_str(req, "name")
let ver = path_str(req, "version")
// tar.bytes contains the raw tarball bytes; tar.filename is the
// original filename sent by the client.
// Verify sha256(tar.bytes) against meta, write to store, etc.
json_response("\{\"ok\": true, \"package\": \"" + name + "\"\}")
}
}
}
}
fun main() {
serve_routes(8080, [
put("/api/v1/packages/{name}/{version}", (req) => handle_publish(req))
])
}Test with curl:
curl -X PUT http://localhost:8080/api/v1/packages/json/0.1.0 \
-F metadata='{"name":"json","version":"0.1.0","checksum":"sha256:abc"}' \
-F tarball=@json-0.1.0.tar.gzPart {
name : string // form-field name (always present)
filename : string // original filename; "" for non-file fields
content_type : string // MIME type from the part header; "" if absent
bytes : string // raw bytes (binary-safe)
}
| Function | Description |
|---|---|
req_part(req, name) |
Get a named part; None if absent or the request is not multipart |
req_parts(req) |
All parts as a list (empty for non-multipart requests) |
req_files(req) |
Only parts that carry a filename (file upload parts) |
is_multipart(req) |
true if the request is multipart and at least one part was parsed |
part_str(req, name) |
Convenience — bytes of a named text part as maybe<string> |
Non-file form fields (sent without a filename attribute) appear as parts with
filename == "". Use req_part to read them by name, or iterate req_parts
to process all parts. Text fields are available immediately as Part.bytes; no
URL-decoding is needed (MHD handles that).
See examples/server_multipart.hc for a
runnable publish-endpoint example.
For full control, use serve directly without the router. Your handler
receives a raw server_request; read its fields with the accessor helpers.
import "http_server"
fun main() {
serve(8080, (req) => {
if request_path(req) == "/health" {
text_response("ok")
} else {
not_found_response()
}
})
}| Function | Description |
|---|---|
serve(port, handler) |
Start serving; every request goes to handler. Never returns |
request_method(req) |
Request method as string |
request_path(req) |
Request path as string |
request_query(req) |
Raw query string ("k=v&k2=v2") |
request_headers(req) |
Raw request headers ("Name: Value\n...") |
request_body(req) |
Request body as string |
Build and run a server:
hica build examples/server.hc -o server
./serverSee examples/server.hc for the low-level API and
examples/server_router.hc for the router with
JSON request/response handling.
Because the HTTP server is single-threaded, a blocking call inside a handler (like a heavy computation or database query) blocks the entire server.
To solve this, Hica supports cooperative concurrency and interleaves request processing with background actors. Rather than blocking the main thread, the handler immediately returns 202 Accepted after enqueuing the payload, and a single-threaded cooperative event loop drives both server polling and background actors together.
For cooperative concurrency, use the non-blocking polling APIs:
| Function | Description |
|---|---|
server_init(port, handler) |
Initialize the server on a port without starting the blocking serve loop |
server_poll(srv) |
Process a single-step non-blocking HTTP event loop cycle. Returns 1 if active |
server_stop(srv) |
Stop the server and clean up resources |
An incoming webhook is instantly queued, and the main cooperative loop schedules tasks to a background WebhookWorker actor:
import "http_server"
import "std/actor"
type WorkerMsg {
ProcessPayload(data: string)
}
actor WebhookWorker {
var processed_count = 0
receive(msg) => match msg {
ProcessPayload(data) => {
processed_count = processed_count + 1
println(" [Worker] Processed webhook #" + show(processed_count) + " data: " + data)
}
}
}
pub fun main() {
println("Initializing cooperative HTTP server on port 8080...")
var worker_state = WebhookWorkerState { processed_count: 0 }
var pending_payloads = []
// Non-blocking server initialization
let srv = server_init(8080, (req) => {
let payload = request_body(req)
pending_payloads = pending_payloads + [payload]
accepted("\{\"status\": \"queued\"\}")
})
// Start the cooperative polling loop
run_loop(srv, worker_state, pending_payloads)
server_stop(srv)
}
pub fun run_loop(srv: int, worker: WebhookWorkerState, payloads: list<string>) : () {
// Process one HTTP server poll cycle
let _ = server_poll(srv)
// Dispatch pending tasks to the actor cooperatively
var next_worker = worker
var next_payloads = payloads
match payloads {
[] => ()
[payload, ..rest] => {
next_worker = webhookworker_receive(worker, ProcessPayload(payload))
next_payloads = rest
}
}
// Recurse to keep the single-threaded event loop alive
run_loop(srv, next_worker, next_payloads)
}See examples/server_cooperative_worker.hc for a complete, runnable simulation.
testclient.hc dispatches requests straight through your routes without opening
a socket, so you can unit-test an app with hica test — no running server, no
ports. Every helper returns a route_response; read it with
route_response_status, route_response_headers, and route_response_body.
import "../src/testclient"
fun app() => [
get("/items/\{id\}", (req) => json_response("\{\"id\": " + show(path_int(req, "id")) + "\}")),
post("/items", (req) => status_response(201, req_body(req)))
]
test "GET /items/7 returns 200" {
let r = test_get(app(), "/items/7")
assert(route_response_status(r) == 200)
}
test "a missing route is a 404" {
let r = test_get(app(), "/nope")
assert(route_response_status(r) == 404)
}| Function | Description |
|---|---|
test_get(routes, path) |
Dispatch a GET. path may include a query string (/items?page=2) |
test_post(routes, path, body) |
Dispatch a POST with a body |
test_put(routes, path, body) |
Dispatch a PUT with a body |
test_patch(routes, path, body) |
Dispatch a PATCH with a body |
test_delete(routes, path) |
Dispatch a DELETE |
test_request(routes, method, path, headers, body) |
Full control, including raw request headers |
test_request_mw(routes, middlewares, method, path, headers, body) |
Dispatch through the middleware chain too |
static.hc serves files from a directory. Mount it on a route with a trailing
* wildcard, which captures the rest of the path:
import "../src/static"
// A named handler that serves files from ./public via the "*" capture.
fun assets(req) => serve_file_from(req, "./public")
fun main() {
serve_routes(8080, [
get("/", (req) => html_response("<h1>Home</h1>")),
get("/static/*", assets)
])
}A request for /static/css/app.css reads ./public/css/app.css. The
Content-Type is chosen from the file extension; an empty capture serves
index.html; a missing file is a 404; and any attempt to escape the directory
(a .. segment or an absolute path) is a 403.
| Function | Description |
|---|---|
serve_file_from(req, dir) |
Serve the *-captured path from under dir (403 unsafe / 404 missing) |
content_type_for(path) |
Content-Type for a file extension |
is_safe_rel(rel) |
False if rel escapes the directory (.. or absolute) |
The * wildcard is a general router feature — a "/prefix/*" pattern matches
any remaining path and captures it under path_str(req, "*").
Notes:
serve_file_frommust be wrapped in a named handler (a returned closure that reads the filesystem does not currently type-check in hica). Files are read as UTF-8 text, so binary assets like images are not yet supported.
MIT