diff --git a/next/kernel/http_server.erl b/next/kernel/http_server.erl index d47478b3..1cc5e49a 100644 --- a/next/kernel/http_server.erl +++ b/next/kernel/http_server.erl @@ -9,7 +9,8 @@ activity_path/0, unauthorized_response/0, post_activity_response/0, validation_failed_response/0, - cid_response/1]). + cid_response/1, + accept_format/1, accept_format_from/1]). %% HTTP request router per design §16.1. %% @@ -283,3 +284,75 @@ expected_token(Cfg) -> 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. diff --git a/next/tests/http_accept.sh b/next/tests/http_accept.sh new file mode 100755 index 00000000..7b06a560 --- /dev/null +++ b/next/tests/http_accept.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# next/tests/http_accept.sh — Step 8d-accept acceptance test. +# +# Exercises accept_format/1 + accept_format_from/1. 12 cases. + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}" +if [ ! -x "$SX_SERVER" ]; then + SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe" +fi +if [ ! -x "$SX_SERVER" ]; then + echo "ERROR: sx_server.exe not found." >&2 + exit 1 +fi + +VERBOSE="${1:-}" +PASS=0; FAIL=0; ERRORS="" +TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT + +cat > "$TMPFILE" <<'EPOCHS' +(epoch 1) +(load "lib/erlang/tokenizer.sx") +(load "lib/erlang/parser.sx") +(load "lib/erlang/parser-core.sx") +(load "lib/erlang/parser-expr.sx") +(load "lib/erlang/parser-module.sx") +(load "lib/erlang/transpile.sx") +(load "lib/erlang/runtime.sx") +(load "lib/erlang/vm/dispatcher.sx") + +(epoch 2) +(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)") + +;; activity_json +(epoch 10) +(eval "(get (erlang-eval-ast \"http_server:accept_format(<<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>>)\") :name)") + +;; json +(epoch 11) +(eval "(get (erlang-eval-ast \"http_server:accept_format(<<97,112,112,108,105,99,97,116,105,111,110,47,106,115,111,110>>)\") :name)") + +;; sx +(epoch 12) +(eval "(get (erlang-eval-ast \"http_server:accept_format(<<97,112,112,108,105,99,97,116,105,111,110,47,115,120>>)\") :name)") + +;; cbor +(epoch 13) +(eval "(get (erlang-eval-ast \"http_server:accept_format(<<97,112,112,108,105,99,97,116,105,111,110,47,99,98,111,114>>)\") :name)") + +;; text/plain -> text +(epoch 14) +(eval "(get (erlang-eval-ast \"http_server:accept_format(<<116,101,120,116,47,112,108,97,105,110>>)\") :name)") + +;; nil -> text +(epoch 15) +(eval "(get (erlang-eval-ast \"http_server:accept_format(nil)\") :name)") + +;; empty binary -> text +(epoch 16) +(eval "(get (erlang-eval-ast \"http_server:accept_format(<<>>)\") :name)") + +;; activity_json wins over json when both present at the start +;; "application/activity+json, application/json" +(epoch 17) +(eval "(get (erlang-eval-ast \"http_server:accept_format(<<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,44,32,97,112,112,108,105,99,97,116,105,111,110,47,106,115,111,110>>)\") :name)") + +;; accept_format_from with no header field -> text +(epoch 18) +(eval "(get (erlang-eval-ast \"http_server:accept_format_from([])\") :name)") + +;; accept_format_from with Accept header +(epoch 19) +(eval "(get (erlang-eval-ast \"AK = <<97,99,99,101,112,116>>, AV = <<97,112,112,108,105,99,97,116,105,111,110,47,115,120>>, http_server:accept_format_from([{headers, [{AK, AV}]}])\") :name)") + +;; accept_format_from with headers but no Accept -> text +(epoch 20) +(eval "(get (erlang-eval-ast \"OK = <<102,111,111>>, http_server:accept_format_from([{headers, [{OK, <<98,97,114>>}]}])\") :name)") + +;; accept_format on a non-binary returns text +(epoch 21) +(eval "(get (erlang-eval-ast \"http_server:accept_format(some_atom)\") :name)") +EPOCHS + +OUTPUT=$(timeout 60 "$SX_SERVER" < "$TMPFILE" 2>/dev/null) + +check() { + local epoch="$1" desc="$2" expected="$3" + local actual + actual=$(echo "$OUTPUT" | awk -v e="$epoch" ' + $0 ~ "^\\(ok-len " e " " { getline; print; exit } + $0 ~ "^\\(ok " e " " { print; exit } + $0 ~ "^\\(error " e " " { print; exit } + ') + [ -z "$actual" ] && actual="" + if echo "$actual" | grep -qF -- "$expected"; then + PASS=$((PASS+1)) + [ "$VERBOSE" = "-v" ] && echo " ok $desc" + else + FAIL=$((FAIL+1)) + ERRORS+=" FAIL [$desc] (epoch $epoch) expected: $expected | actual: $actual +" + fi +} + +check 2 "module load name" "http_server" +check 10 "activity+json -> activity_json" "activity_json" +check 11 "json -> json" "json" +check 12 "sx -> sx" "sx" +check 13 "cbor -> cbor" "cbor" +check 14 "text/plain -> text" "text" +check 15 "nil -> text" "text" +check 16 "empty binary -> text" "text" +check 17 "activity+json wins over json" "activity_json" +check 18 "no headers -> text" "text" +check 19 "Accept: application/sx -> sx" "sx" +check 20 "no Accept header -> text" "text" +check 21 "non-binary input -> text" "text" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/http_accept.sh passed" +else + echo "FAIL $PASS/$TOTAL passed, $FAIL failed:" + echo "$ERRORS" +fi +[ $FAIL -eq 0 ] diff --git a/plans/fed-sx-milestone-1.md b/plans/fed-sx-milestone-1.md index 0c2b49ff..2e6f5365 100644 --- a/plans/fed-sx-milestone-1.md +++ b/plans/fed-sx-milestone-1.md @@ -517,7 +517,8 @@ publish(ActorId, ActivityRequest) -> - [x] **8c-post-publish-pure** — `next/kernel/nx_kernel.erl` — pure-functional kernel orchestrator. `new/3(ActorId, KeySpec, ActorState)` builds the runtime state; `publish/2(Request, State)` calls `outbox:publish` with a Context derived from state, advances log + next_published on success. `next/tests/nx_kernel_pure.sh` (12 cases). - [x] **8c-post-publish-srv** — gen_server wrapper around nx_kernel: `start_link/3`, named-process `publish/1`, `query/0`, `log_tip/0`, `with_projections/1`, `stop/0`. `next/tests/nx_kernel_server.sh` (11 cases). HTTP layer integration follows. - [x] **8c-post-publish-http** — POST `/activity` handler now calls `nx_kernel:publish/1` when the kernel process is registered; falls back to the existing stub when not. Success → 200 with `cid: \n` body via `cid_response/1`; sig/replay failures → 422 via `validation_failed_response/0`. `next/tests/http_publish.sh` (10 cases). -- [ ] **8d** — Content negotiation by Accept header: application/activity+json (default), application/cbor, application/json, application/sx. +- [x] **8d-accept** — `accept_format/1` + `accept_format_from/1` parse the Accept header into `:activity_json | :json | :sx | :cbor | :text`. Priority: activity+json > json > sx > cbor; everything else falls to text. `next/tests/http_accept.sh` (13 cases). +- [ ] **8d-dispatch** — Wire format dispatch into responses (e.g., the `cid_response`/`actor_doc_response`/etc. emit body shapes matching the chosen format). **Deliverables:** @@ -996,6 +997,7 @@ A few things still under-specified; resolve as work begins. Newest first. One line per sub-deliverable commit. Erlang conformance gate (`bash lib/erlang/conformance.sh`) must remain 729/729 on every entry. +- **2026-05-28** — Step 8d-accept: `accept_format/1` + `accept_format_from/1` parse the Accept header into a content-negotiation atom. Priority order via successive `match_prefix` checks: application/activity+json → `activity_json`; application/json → `json`; application/sx → `sx`; application/cbor → `cbor`; everything else (including nil / empty / non-binary) → `text`. Comma-separated lists with activity+json first still resolve to activity_json — leading-prefix match is sufficient for v1 envelopes. Step 8d split into 8d-accept (done) + 8d-dispatch (wire into response bodies). `next/tests/http_accept.sh` 13/13. Erlang conformance 729/729. - **2026-05-28** — Step 5c-populate: `bootstrap:populate_registry/0` walks `read_genesis` output and calls `registry:register/3` (gen_server API) for each entry. Total return is 31 = 3 + 10 + 7 + 3 + 3 + 2 + 3 across the seven kinds, matching the manifest authored in Step 4. `next/tests/bootstrap_populate.sh` 14/14 verifies per-kind counts + lookups against known names (`activity_types/create`, `object_types/define-activity`, `validators/envelope-shape`). Erlang conformance 729/729. - **2026-05-28** — Step 9-pre-fold: in-process integration test proving the full POST → publish → broadcast → projection-fold chain. With `projection:start_link` + `nx_kernel:start_link` + `nx_kernel:with_projections([p_count, p_collect])`, three authorized POST `/activity` calls advance both projections to 3 — and the kernel's log to 3 entries — and the projection's collected activity carries the POST body as `:object`. Unauthorized or sig-failed POSTs leave projection state unchanged. Step 9a/b proper (curl-driven smoke tests) wait on Step 8b-start (TCP) + Define\* SX-source eval bridge, but the structural chain is already verified end-to-end. `next/tests/http_publish_fold.sh` 10/10. Erlang conformance 729/729. Step 9 split into 9-pre-fold (done) + 9a + 9b. - **2026-05-28** — Step 8c-post-publish-http: POST `/activity` handler now bridges into `nx_kernel:publish/1` when the kernel gen_server is registered (`erlang:whereis(nx_kernel) =/= undefined`). On success the response carries the canonical CID via `cid_response/1`; on pipeline failure the response is 422 via `validation_failed_response/0`. When the kernel isn't registered, the handler falls through to the existing 200 stub — preserves backwards compatibility for the auth-only tests in `http_post_activity.sh`. Distinct POSTs produce distinct CIDs (next_published counter in nx_kernel state). Unauthorized POSTs never reach the kernel — log tip stays at 0. `next/tests/http_publish.sh` 10/10. The POST `/activity` → publish → fold loop is now functional end-to-end through the kernel. Erlang conformance 729/729.