fed-sx-m1: Step 8d-accept — Accept header parsing (accept_format/1 + accept_format_from/1) + 13 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 27s

This commit is contained in:
2026-05-28 13:57:48 +00:00
parent cd7693d443
commit fa064093f5
3 changed files with 205 additions and 2 deletions

View File

@@ -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.

128
next/tests/http_accept.sh Executable file
View File

@@ -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="<no output for epoch $epoch>"
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 ]

View File

@@ -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: <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.