From 1aaede42723786b30c18e3bb8e722bffe1fce835 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 28 May 2026 15:04:46 +0000 Subject: [PATCH] =?UTF-8?q?fed-sx-m1:=20Step=208d-content-type=20=E2=80=94?= =?UTF-8?q?=20content=5Ftype=5Ffor/1=20+=20ok=5Fresponse/2=20+=2013=20test?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next/kernel/http_server.erl | 36 +++++++++- next/tests/http_content_type.sh | 119 ++++++++++++++++++++++++++++++++ plans/fed-sx-milestone-1.md | 4 +- 3 files changed, 157 insertions(+), 2 deletions(-) create mode 100755 next/tests/http_content_type.sh diff --git a/next/kernel/http_server.erl b/next/kernel/http_server.erl index cf1a7482..7f7fe6e5 100644 --- a/next/kernel/http_server.erl +++ b/next/kernel/http_server.erl @@ -11,7 +11,8 @@ validation_failed_response/0, cid_response/1, accept_format/1, accept_format_from/1, - capabilities_body_for/1]). + capabilities_body_for/1, + content_type_for/1, ok_response/2]). %% HTTP request router per design §16.1. %% @@ -389,3 +390,36 @@ capabilities_body_for(cbor) -> 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}]. diff --git a/next/tests/http_content_type.sh b/next/tests/http_content_type.sh new file mode 100755 index 00000000..7654f3a0 --- /dev/null +++ b/next/tests/http_content_type.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# next/tests/http_content_type.sh — Step 8d-content-type test. +# +# Exercises content_type_for/1 and ok_response/2. 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)") + +;; content_type_for returns the right byte size per format +(epoch 10) +(eval "(erlang-eval-ast \"byte_size(http_server:content_type_for(text))\")") +(epoch 11) +(eval "(erlang-eval-ast \"byte_size(http_server:content_type_for(json))\")") +(epoch 12) +(eval "(erlang-eval-ast \"byte_size(http_server:content_type_for(activity_json))\")") +(epoch 13) +(eval "(erlang-eval-ast \"byte_size(http_server:content_type_for(sx))\")") +(epoch 14) +(eval "(erlang-eval-ast \"byte_size(http_server:content_type_for(cbor))\")") + +;; All content types are distinct +(epoch 15) +(eval "(get (erlang-eval-ast \"T = http_server:content_type_for(text), J = http_server:content_type_for(json), AJ = http_server:content_type_for(activity_json), S = http_server:content_type_for(sx), C = http_server:content_type_for(cbor), (T =/= J) and (J =/= AJ) and (AJ =/= S) and (S =/= C) and (T =/= C)\") :name)") + +;; Unknown format -> text Content-Type +(epoch 16) +(eval "(get (erlang-eval-ast \"http_server:content_type_for(weird) =:= http_server:content_type_for(text)\") :name)") + +;; ok_response/2 has shape [{status, 200}, {headers, [{ct, ...}]}, {body, ...}] +(epoch 17) +(eval "(get (erlang-eval-ast \"R = http_server:ok_response(<<1,2>>, json), case R of [{status, 200}, {headers, [{<<99,111,110,116,101,110,116,45,116,121,112,101>>, _}]}, {body, <<1,2>>}] -> ok; _ -> bad end\") :name)") + +;; ok_response/2's CT value matches content_type_for for that format +(epoch 18) +(eval "(get (erlang-eval-ast \"R = http_server:ok_response(<<>>, sx), case R of [_, {headers, [{_, CT}]}, _] -> CT =:= http_server:content_type_for(sx); _ -> false end\") :name)") + +;; ok_response/2 carries the body unchanged +(epoch 19) +(eval "(get (erlang-eval-ast \"R = http_server:ok_response(<<104,105>>, cbor), case R of [_, _, {body, <<104,105>>}] -> ok; _ -> bad end\") :name)") + +;; activity_json starts with 'application' (97) +(epoch 20) +(eval "(get (erlang-eval-ast \"case http_server:content_type_for(activity_json) of <<97, _/binary>> -> ok; _ -> bad end\") :name)") + +;; Existing ok_response/1 still works (backwards compat) +(epoch 21) +(eval "(get (erlang-eval-ast \"R = http_server:ok_response(<<1,2,3>>), case R of [{status, 200}, {headers, []}, {body, <<1,2,3>>}] -> ok; _ -> bad end\") :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 "text -> 'text/plain' (10b)" "10" +check 11 "json -> 'application/json' (16b)" "16" +check 12 "activity_json (25b)" "25" +check 13 "sx (14b)" "14" +check 14 "cbor (16b)" "16" +check 15 "all CTs distinct" "true" +check 16 "unknown -> text" "true" +check 17 "ok_response/2 shape" "ok" +check 18 "ok_response/2 CT matches" "true" +check 19 "body carried through" "ok" +check 20 "activity_json starts 'a'" "ok" +check 21 "ok_response/1 backward-compat" "ok" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/http_content_type.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 c0e07381..14807149 100644 --- a/plans/fed-sx-milestone-1.md +++ b/plans/fed-sx-milestone-1.md @@ -519,7 +519,8 @@ publish(ActorId, ActivityRequest) -> - [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). - [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). - [x] **8d-dispatch-cap** — `capabilities_body_for/1` returns distinct stubs per format (json `{...}`, sx `(...)`, cbor `A1 64 caps 69 fed-sx-m1`); activity_json shares the json body. Route intercepts GET capabilities to thread the Accept format through `accept_format_from/1`. `next/tests/http_capabilities_format.sh` (13 cases). -- [ ] **8d-dispatch-rest** — Same treatment for `actor_doc_response`, `artifact_response`, `projection_response`, `cid_response` (plus Content-Type headers). +- [x] **8d-content-type** — `content_type_for/1` maps format atoms to MIME-type binaries (text/plain, application/json, application/activity+json, application/sx, application/cbor). `ok_response/2(Body, Format)` builds a 200 response with the right Content-Type header. `next/tests/http_content_type.sh` (13 cases). +- [ ] **8d-dispatch-rest** — Wire `ok_response/2` + format dispatch into `actor_doc_response`, `artifact_response`, `projection_response`, `cid_response`. **Deliverables:** @@ -998,6 +999,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-content-type: `content_type_for/1` maps format atoms to MIME-type binaries — text/plain (10b), application/json (16b), application/activity+json (25b), application/sx (14b), application/cbor (16b); unknown formats fall through to text/plain. `ok_response/2(Body, Format)` constructs a 200 response with `{headers, [{<<"content-type">>, MIME}]}`. Lowercase header key matches how the BIF wrapper normalises request headers. `ok_response/1` still produces the empty-headers shape — backward compat preserved. `next/tests/http_content_type.sh` 13/13. Erlang conformance 729/729. - **2026-05-28** — Step 8d-dispatch-cap: `capabilities_body_for/1` returns distinct byte sequences per format — text reuses the existing `capabilities_body/0`; json/activity_json share `{"caps":"fed-sx-m1"}`; sx returns `(caps "fed-sx-m1")`; cbor returns a minimal `A1 64 caps 69 fed-sx-m1` map. Route now intercepts GET `/.well-known/sx-capabilities` to pull the Accept format via `accept_format_from/1` and dispatch through `capabilities_body_for`. Unknown formats fall back to text. POST capabilities still 404 (only GET handled). `next/tests/http_capabilities_format.sh` 13/13 verifies all formats + the intercept + no-Accept default. Content-Type headers not yet set (8d-dispatch-rest covers headers + applying the same shape to actor/artifact/projection/cid responses). Erlang conformance 729/729. - **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.