Files
rose-ash/next/kernel/http_server.erl
2026-05-28 16:28:07 +00:00

587 lines
22 KiB
Erlang

-module(http_server).
-export([route/1, route/2, ok_response/1, not_found_response/0,
welcome_body/0, capabilities_body/0,
capabilities_path/0,
match_prefix/2, actors_prefix/0, actor_doc_response/1,
artifacts_prefix/0, artifact_response/1,
projections_list_path/0, projections_prefix/0,
projections_list_response/0, projection_response/1,
activity_path/0, unauthorized_response/0,
post_activity_response/0,
validation_failed_response/0,
cid_response/1,
accept_format/1, accept_format_from/1,
capabilities_body_for/1,
content_type_for/1, ok_response/2,
cid_response_for/2, post_activity_response_for/1,
actor_doc_response_for/2, artifact_response_for/2,
projection_response_for/2, projections_list_response_for/1]).
%% HTTP request router per design §16.1.
%%
%% Request shape (mirrors what the SX-side `http-listen` builds and
%% the http:listen/2 BIF bridge marshals into a proplist):
%% [{method, Binary}, {path, Binary}, {query, Binary},
%% {headers, [{Name, Value}, ...]}, {body, Binary}]
%%
%% Response shape:
%% [{status, Integer}, {headers, [{Name, Value}, ...]}, {body, Binary}]
%%
%% Real dispatch (actor docs, outbox listings, /activity POST,
%% /.well-known/sx-capabilities, etc.) lands in Step 8c+. Step 8b
%% wires the route/1 shape and a single hello-world handler that
%% proves the request→response round-trip.
%%
%% Method/path comparison uses integer-segment binaries because
%% `<<"GET">>` truncates to a single byte in this port.
route(Req) ->
route(Req, []).
%% route/2 — Cfg proplist carries optional `:publish_token` (binary)
%% for POST /activity auth. Other state (logs, projections, etc.) is
%% not yet threaded through — POST /activity returns a stub 200
%% once auth succeeds; real outbox:publish glue lands separately.
route(Req, Cfg) ->
M = field(method, Req),
P = field(path, Req),
F = accept_format_from(Req),
case {M, P} of
{<<80,79,83,84>>, <<47,97,99,116,105,118,105,116,121>>} ->
handle_post_activity(Req, Cfg);
{<<71,69,84>>,
<<47,46,119,101,108,108,45,107,110,111,119,110,
47,115,120,45,99,97,112,97,98,105,108,105,116,105,101,115>>} ->
ok_response(capabilities_body_for(F));
_ ->
dispatch(M, P, F)
end.
%% Backward-compat /2 wrapper — defaults to text format. Route
%% computes Format from the Accept header and calls dispatch/3
%% directly; dispatch/2 is kept for callers that don't have a
%% format in scope.
dispatch(M, P) ->
dispatch(M, P, text).
%% 71 69 84 = "GET" | 47 = "/"
dispatch(<<71, 69, 84>>, <<47>>, _F) ->
ok_response(welcome_body());
%% GET /.well-known/sx-capabilities — Format threaded through
dispatch(<<71, 69, 84>>,
<<47,46,119,101,108,108,45,107,110,111,119,110,
47,115,120,45,99,97,112,97,98,105,108,105,116,105,101,115>>, F) ->
ok_response(capabilities_body_for(F));
%% GET /projections — list stub. Comes before the /projections/{name}
%% prefix clause because the bare path has no trailing slash.
dispatch(<<71, 69, 84>>, <<47,112,114,111,106,101,99,116,105,111,110,115>>, F) ->
projections_list_response_for(F);
%% GET /actors/{id} or /artifacts/{cid} or /projections/{name}
dispatch(<<71, 69, 84>>, Path, F) ->
case match_prefix(actors_prefix(), Path) of
{ok, Id} when byte_size(Id) > 0 ->
actor_doc_response_for(Id, F);
_ ->
case match_prefix(artifacts_prefix(), Path) of
{ok, Cid} when byte_size(Cid) > 0 ->
artifact_response_for(Cid, F);
_ ->
case match_prefix(projections_prefix(), Path) of
{ok, Name} when byte_size(Name) > 0 ->
projection_response_for(Name, F);
_ ->
not_found_response()
end
end
end;
dispatch(_, _, _) ->
not_found_response().
%% "fed-sx kernel m1\n" — 17 bytes, hand-spelled.
%% f e d - s x _ k e r n e l _ m 1 \n
welcome_body() ->
<<102,101,100,45,115,120,32,107,101,114,110,101,108,32,109,49,10>>.
%% "/.well-known/sx-capabilities" — exposed for callers that build
%% requests in tests or that need the canonical path string.
capabilities_path() ->
<<47,46,119,101,108,108,45,107,110,111,119,110,
47,115,120,45,99,97,112,97,98,105,108,105,116,105,101,115>>.
%% Capability descriptor body. Returned as plain text per design
%% §16; future content-negotiation work (Step 8d) layers JSON /
%% dag-cbor / SX representations on top.
%%
%% Lines (each terminated by \n = 10):
%% "kernel: fed-sx-m1\n"
%% "version: 0.0.1\n"
%% "verbs: Create Update Delete\n"
capabilities_body() ->
<<107,101,114,110,101,108,58,32,102,101,100,45,115,120,45,109,49,10,
118,101,114,115,105,111,110,58,32,48,46,48,46,49,10,
118,101,114,98,115,58,32,67,114,101,97,116,101,32,85,112,100,97,116,101,32,68,101,108,101,116,101,10>>.
ok_response(Body) ->
[{status, 200}, {headers, []}, {body, Body}].
not_found_response() ->
[{status, 404}, {headers, []},
{body, <<110,111,116,32,102,111,117,110,100,10>>}]. % "not found\n"
%% Internal property-list field lookup. Returns nil when missing
%% so the route falls into the not_found arm gracefully.
field(K, [{K, V} | _]) -> V;
field(K, [_ | Rest]) -> field(K, Rest);
field(_, []) -> nil.
%% ── Dynamic-segment routing ─────────────────────────────────────
%%
%% match_prefix(Prefix, Path) — if Path starts with the entire
%% Prefix binary, return {ok, Rest} where Rest is the remaining
%% bytes; else return nomatch. Pure byte-level pattern match,
%% no regex / no parsing. Path-segment splitting comes in later
%% sub-deliverables (8c-art, 8c-proj) where it's needed.
match_prefix(<<>>, Rest) -> {ok, Rest};
match_prefix(<<B, PRest/binary>>, <<B, PathRest/binary>>) ->
match_prefix(PRest, PathRest);
match_prefix(_, _) -> nomatch.
%% "/actors/" — 8 bytes: 47 97 99 116 111 114 115 47
actors_prefix() ->
<<47,97,99,116,111,114,115,47>>.
%% Actor doc stub. Real implementation (Step 8c continuation) will
%% fetch the actor-state projection entry and serialise it; v1
%% returns the id as the body so route resolution can be exercised
%% end-to-end without the projection wiring.
actor_doc_response(Id) ->
%% "actor: " — 7 bytes
Pre = <<97,99,116,111,114,58,32>>,
Body = <<Pre/binary, Id/binary, 10>>,
ok_response(Body).
%% "/artifacts/" — 11 bytes
artifacts_prefix() ->
<<47,97,114,116,105,102,97,99,116,115,47>>.
%% Artifact stub. Real implementation will fetch the bytes from
%% the registry (or a CID-keyed store) and content-negotiate.
%% v1 echoes the CID so route resolution can be tested.
artifact_response(Cid) ->
%% "artifact: " — 10 bytes
Pre = <<97,114,116,105,102,97,99,116,58,32>>,
Body = <<Pre/binary, Cid/binary, 10>>,
ok_response(Body).
%% "/projections" — 12 bytes (no trailing slash; the list endpoint)
projections_list_path() ->
<<47,112,114,111,106,101,99,116,105,111,110,115>>.
%% "/projections/" — 13 bytes (the per-projection prefix)
projections_prefix() ->
<<47,112,114,111,106,101,99,116,105,111,110,115,47>>.
%% Stub list response — real implementation queries the registry
%% for active projections and serialises the name+CID list.
projections_list_response() ->
%% "projections: (empty)\n" — hand-spelled
Body = <<112,114,111,106,101,99,116,105,111,110,115,58,32,
40,101,109,112,116,121,41,10>>,
ok_response(Body).
projection_response(Name) ->
%% "projection: " — 12 bytes
Pre = <<112,114,111,106,101,99,116,105,111,110,58,32>>,
Body = <<Pre/binary, Name/binary, 10>>,
ok_response(Body).
%% "/activity" — 9 bytes
activity_path() ->
<<47,97,99,116,105,118,105,116,121>>.
%% 401 Unauthorized response. Body: "unauthorized\n" = 13 bytes.
unauthorized_response() ->
[{status, 401}, {headers, []},
{body, <<117,110,97,117,116,104,111,114,105,122,101,100,10>>}].
%% Stub success body for POST /activity. Real impl will return
%% the published activity's CID once outbox:publish is wired
%% through a server-state context (Step 8c-post-publish).
post_activity_response() ->
%% "published (stub)\n" — hand-spelled
Body = <<112,117,98,108,105,115,104,101,100,32,
40,115,116,117,98,41,10>>,
ok_response(Body).
%% Auth helpers.
handle_post_activity(Req, Cfg) ->
case check_bearer(Req, Cfg) of
ok ->
F = accept_format_from(Req),
publish_if_kernel(Req, F);
{error, _} ->
unauthorized_response()
end.
%% publish_if_kernel/2 — if the nx_kernel gen_server is registered,
%% delegate the publish there and translate the result. Otherwise
%% keep the stub response so the auth-only tests stay green without
%% having to spin up a kernel process. Format threads through to
%% both stub and CID responses so the Content-Type matches what
%% the client asked for via Accept.
publish_if_kernel(Req, F) ->
case erlang:whereis(nx_kernel) of
undefined ->
post_activity_response_for(F);
_Pid ->
Body = field(body, Req),
Request = [{type, create}, {object, Body}],
case nx_kernel:publish(Request) of
{ok, Result} ->
case envelope:get_field(cid, Result) of
{ok, Cid} -> cid_response_for(Cid, F);
_ -> post_activity_response_for(F)
end;
{error, _} ->
validation_failed_response()
end
end.
%% 200 OK with body "cid: <cid>\n" (5 prefix bytes + cid + newline)
cid_response(Cid) ->
%% "cid: " — 99 105 100 58 32
Pre = <<99,105,100,58,32>>,
Body = <<Pre/binary, Cid/binary, 10>>,
ok_response(Body).
%% 422 Unprocessable Entity. Body "validation failed\n" — 18 bytes.
validation_failed_response() ->
[{status, 422}, {headers, []},
{body, <<118,97,108,105,100,97,116,105,111,110,32,
102,97,105,108,101,100,10>>}].
check_bearer(Req, Cfg) ->
case bearer_token(Req) of
{ok, Got} ->
case expected_token(Cfg) of
{ok, Want} when Got =:= Want -> ok;
_ -> {error, bad_token}
end;
not_found -> {error, no_auth}
end.
%% Look up the Authorization header, strip "Bearer ", return token.
bearer_token(Req) ->
case field(headers, Req) of
nil -> not_found;
Hs ->
%% "authorization" — 13 bytes, lowercase as the BIF wrapper
%% normalises headers to lowercase keys.
AuthKey = <<97,117,116,104,111,114,105,122,97,116,105,111,110>>,
case find_header(AuthKey, Hs) of
not_found -> not_found;
{ok, V} -> strip_bearer(V)
end
end.
find_header(_, []) -> not_found;
find_header(K, [{K, V} | _]) -> {ok, V};
find_header(K, [_ | Rest]) -> find_header(K, Rest).
%% "Bearer " — 7 bytes — strip and return the rest as the token.
%% Anything else returns not_found (treated as missing auth).
strip_bearer(V) ->
Prefix = <<66,101,97,114,101,114,32>>,
case match_prefix(Prefix, V) of
{ok, Token} when byte_size(Token) > 0 -> {ok, Token};
_ -> not_found
end.
expected_token(Cfg) ->
case field(publish_token, Cfg) of
nil -> not_found;
T -> {ok, T}
end.
%% ── Step 8d: Accept-header parsing ──────────────────────────────
%%
%% accept_format/1 — given an Accept header value, return the
%% content-negotiation atom the route should serialise into. The
%% first media-type prefix that matches wins, in this priority:
%% application/activity+json -> activity_json
%% application/json -> json
%% application/sx -> sx
%% application/cbor -> cbor
%% Anything else (including unrecognised, empty, or missing header)
%% returns text — current routes default to text/plain bodies.
%%
%% Per-prefix recognition uses `match_prefix`. The header value is
%% NOT split on `,` here; matching against the leading bytes is
%% enough for the v1 envelope shapes the kernel currently emits.
%% Media-type prefix byte sequences — hand-spelled because
%% `<<"...">>` string-segments truncate in this port.
%% "application/activity+json" — 25 bytes
activity_json_prefix() ->
<<97,112,112,108,105,99,97,116,105,111,110,47,
97,99,116,105,118,105,116,121,43,106,115,111,110>>.
%% "application/json" — 16 bytes
json_prefix() ->
<<97,112,112,108,105,99,97,116,105,111,110,47,106,115,111,110>>.
%% "application/sx" — 14 bytes
sx_prefix() ->
<<97,112,112,108,105,99,97,116,105,111,110,47,115,120>>.
%% "application/cbor" — 16 bytes
cbor_prefix() ->
<<97,112,112,108,105,99,97,116,105,111,110,47,99,98,111,114>>.
accept_format(nil) -> text;
accept_format(<<>>) -> text;
accept_format(V) when is_binary(V) ->
case match_prefix(activity_json_prefix(), V) of
{ok, _} -> activity_json;
_ ->
case match_prefix(json_prefix(), V) of
{ok, _} -> json;
_ ->
case match_prefix(sx_prefix(), V) of
{ok, _} -> sx;
_ ->
case match_prefix(cbor_prefix(), V) of
{ok, _} -> cbor;
_ -> text
end
end
end
end;
accept_format(_) -> text.
%% accept_format_from/1 — pull the Accept header out of a request
%% proplist and run accept_format on its value. Lowercase key name
%% (matches the BIF wrapper's normalisation).
accept_format_from(Req) ->
case field(headers, Req) of
nil -> text;
Hs ->
%% "accept" — 6 bytes
K = <<97,99,99,101,112,116>>,
case find_header(K, Hs) of
{ok, V} -> accept_format(V);
not_found -> text
end
end.
%% capabilities_body_for/1 — content-negotiated capability bodies.
%% Each format returns a distinct byte sequence so dispatch can be
%% observed end-to-end. Real serialisation (JSON-LD, dag-cbor, etc.)
%% lands once the corresponding encoder BIFs are wired; v1 uses
%% tagged stubs that are syntactically the right shape.
capabilities_body_for(text) ->
capabilities_body();
%% `{"caps":"fed-sx-m1"}\n` — 21 bytes
capabilities_body_for(json) ->
<<123,34,99,97,112,115,34,58,34,
102,101,100,45,115,120,45,109,49,34,125,10>>;
capabilities_body_for(activity_json) ->
%% Same payload as :json — the difference is the Content-Type
%% header (Step 8d-content-type follow-up); body shape matches.
capabilities_body_for(json);
%% `(caps "fed-sx-m1")\n` — 19 bytes
capabilities_body_for(sx) ->
<<40,99,97,112,115,32,34,
102,101,100,45,115,120,45,109,49,34,41,10>>;
%% A minimal CBOR map: 0xA1 0x64 "caps" 0x69 "fed-sx-m1"
%% A1 = map(1); 64 = text(4) "caps"; 69 = text(9) "fed-sx-m1"
capabilities_body_for(cbor) ->
<<161,100,99,97,112,115,105,
102,101,100,45,115,120,45,109,49>>;
capabilities_body_for(_) ->
capabilities_body().
%% content_type_for/1 — MIME type binary for each format atom.
%% "text/plain" — 10 bytes
content_type_for(text) ->
<<116,101,120,116,47,112,108,97,105,110>>;
%% "application/json" — 16 bytes
content_type_for(json) ->
<<97,112,112,108,105,99,97,116,105,111,110,47,
106,115,111,110>>;
%% "application/activity+json" — 25 bytes
content_type_for(activity_json) ->
<<97,112,112,108,105,99,97,116,105,111,110,47,
97,99,116,105,118,105,116,121,43,106,115,111,110>>;
%% "application/sx" — 14 bytes
content_type_for(sx) ->
<<97,112,112,108,105,99,97,116,105,111,110,47,
115,120>>;
%% "application/cbor" — 16 bytes
content_type_for(cbor) ->
<<97,112,112,108,105,99,97,116,105,111,110,47,
99,98,111,114>>;
content_type_for(_) ->
content_type_for(text).
%% ok_response/2 — 200 OK with a Content-Type header derived from
%% the Format atom. The header key is lowercase to match how the
%% BIF wrapper normalises request headers.
%% "content-type" — 12 bytes
ok_response(Body, Format) ->
CTKey = <<99,111,110,116,101,110,116,45,116,121,112,101>>,
[{status, 200},
{headers, [{CTKey, content_type_for(Format)}]},
{body, Body}].
%% cid_response_for/2 — format-aware version of cid_response/1.
%% Each variant emits a syntactically appropriate body for the
%% chosen format and tags the response with the matching
%% Content-Type via ok_response/2.
cid_response_for(Cid, text) ->
cid_response(Cid);
%% `{"cid":"<cid>"}\n` — 8-byte prefix + cid + 3-byte suffix
cid_response_for(Cid, json) ->
Pre = <<123,34,99,105,100,34,58,34>>, % '{"cid":"'
Suf = <<34,125,10>>, % '"}\n'
ok_response(<<Pre/binary, Cid/binary, Suf/binary>>, json);
cid_response_for(Cid, activity_json) ->
Pre = <<123,34,99,105,100,34,58,34>>,
Suf = <<34,125,10>>,
ok_response(<<Pre/binary, Cid/binary, Suf/binary>>, activity_json);
%% `(cid "<cid>")\n` — 6-byte prefix + cid + 3-byte suffix
cid_response_for(Cid, sx) ->
Pre = <<40,99,105,100,32,34>>, % '(cid "'
Suf = <<34,41,10>>, % '")\n'
ok_response(<<Pre/binary, Cid/binary, Suf/binary>>, sx);
%% v1 cbor stub: the raw CID bytes with the application/cbor CT.
%% Real cbor encoding (A1 63 cid 78 <len> ...) lands later.
cid_response_for(Cid, cbor) ->
ok_response(Cid, cbor);
cid_response_for(Cid, _) ->
cid_response(Cid).
%% post_activity_response_for/1 — format-aware version of
%% post_activity_response/0 (the kernel-absent stub).
post_activity_response_for(text) ->
post_activity_response();
%% `{"status":"stub"}\n` — hand-spelled
post_activity_response_for(json) ->
Body = <<123,34,115,116,97,116,117,115,34,58,34,
115,116,117,98,34,125,10>>,
ok_response(Body, json);
post_activity_response_for(activity_json) ->
Body = <<123,34,115,116,97,116,117,115,34,58,34,
115,116,117,98,34,125,10>>,
ok_response(Body, activity_json);
%% `(status "stub")\n`
post_activity_response_for(sx) ->
Body = <<40,115,116,97,116,117,115,32,34,
115,116,117,98,34,41,10>>,
ok_response(Body, sx);
post_activity_response_for(cbor) ->
%% Same body as text but with cbor CT — clients see the same
%% bytes as the text fallback. Step 8d-cbor encoder will replace.
[_, _, {body, Body}] = post_activity_response(),
ok_response(Body, cbor);
post_activity_response_for(_) ->
post_activity_response().
%% ── 8d-dispatch-get: format-aware GET responses ─────────────────
%%
%% Each builder mirrors its text-only counterpart but emits a
%% format-tagged body and Content-Type. json/activity_json share
%% the body shape but differ in CT; sx uses parenthesized form;
%% cbor returns the raw payload bytes (encoder follow-up).
%% actor_doc_response — text body `actor: <id>\n`.
actor_doc_response_for(Id, text) ->
actor_doc_response(Id);
actor_doc_response_for(Id, json) ->
Pre = <<123,34,97,99,116,111,114,34,58,34>>, % '{"actor":"'
Suf = <<34,125,10>>, % '"}\n'
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, json);
actor_doc_response_for(Id, activity_json) ->
Pre = <<123,34,97,99,116,111,114,34,58,34>>,
Suf = <<34,125,10>>,
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, activity_json);
actor_doc_response_for(Id, sx) ->
Pre = <<40,97,99,116,111,114,32,34>>, % '(actor "'
Suf = <<34,41,10>>, % '")\n'
ok_response(<<Pre/binary, Id/binary, Suf/binary>>, sx);
actor_doc_response_for(Id, cbor) ->
ok_response(Id, cbor);
actor_doc_response_for(Id, _) ->
actor_doc_response(Id).
%% artifact_response — text body `artifact: <cid>\n`.
artifact_response_for(Cid, text) ->
artifact_response(Cid);
artifact_response_for(Cid, json) ->
Pre = <<123,34,97,114,116,105,102,97,99,116,34,58,34>>,
Suf = <<34,125,10>>,
ok_response(<<Pre/binary, Cid/binary, Suf/binary>>, json);
artifact_response_for(Cid, activity_json) ->
Pre = <<123,34,97,114,116,105,102,97,99,116,34,58,34>>,
Suf = <<34,125,10>>,
ok_response(<<Pre/binary, Cid/binary, Suf/binary>>, activity_json);
artifact_response_for(Cid, sx) ->
Pre = <<40,97,114,116,105,102,97,99,116,32,34>>,
Suf = <<34,41,10>>,
ok_response(<<Pre/binary, Cid/binary, Suf/binary>>, sx);
artifact_response_for(Cid, cbor) ->
ok_response(Cid, cbor);
artifact_response_for(Cid, _) ->
artifact_response(Cid).
%% projection_response (singular) — text body `projection: <name>\n`.
projection_response_for(Name, text) ->
projection_response(Name);
projection_response_for(Name, json) ->
Pre = <<123,34,112,114,111,106,101,99,116,105,111,110,34,58,34>>,
Suf = <<34,125,10>>,
ok_response(<<Pre/binary, Name/binary, Suf/binary>>, json);
projection_response_for(Name, activity_json) ->
Pre = <<123,34,112,114,111,106,101,99,116,105,111,110,34,58,34>>,
Suf = <<34,125,10>>,
ok_response(<<Pre/binary, Name/binary, Suf/binary>>, activity_json);
projection_response_for(Name, sx) ->
Pre = <<40,112,114,111,106,101,99,116,105,111,110,32,34>>,
Suf = <<34,41,10>>,
ok_response(<<Pre/binary, Name/binary, Suf/binary>>, sx);
projection_response_for(Name, cbor) ->
ok_response(Name, cbor);
projection_response_for(Name, _) ->
projection_response(Name).
%% projections_list_response — empty-list stub.
projections_list_response_for(text) ->
projections_list_response();
%% `{"projections":[]}\n`
projections_list_response_for(json) ->
Body = <<123,34,112,114,111,106,101,99,116,105,111,110,115,
34,58,91,93,125,10>>,
ok_response(Body, json);
projections_list_response_for(activity_json) ->
Body = <<123,34,112,114,111,106,101,99,116,105,111,110,115,
34,58,91,93,125,10>>,
ok_response(Body, activity_json);
%% `(projections)\n`
projections_list_response_for(sx) ->
Body = <<40,112,114,111,106,101,99,116,105,111,110,115,41,10>>,
ok_response(Body, sx);
projections_list_response_for(cbor) ->
[_, _, {body, Body}] = projections_list_response(),
ok_response(Body, cbor);
projections_list_response_for(_) ->
projections_list_response().