fed-sx-types Phase 3: /types/<cid> route + discovery_type_fetch
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 51s

Wire format for serving + fetching type docs (plans/fed-sx-host-types.md
step 3).

http_server.erl:
- new type_doc Accept format + content type
  (application/vnd.fed-sx.type-doc), distinct from actor-doc.
- GET /types/<cid> -> the cached TypeRecord term_codec-encoded, 404 if
  not in the peer_types cache. Reads peer_types via a Cfg
  {peer_types, peer_types} guard (hardcoded registered atom, mirroring
  the actor-doc route's kernel guard).

discovery_type_fetch.erl — sibling of discovery_fetch. make_fetch_fn
produces the fun/2 peer_types:lookup_or_fetch calls: GET
<base>/types/<cid> with the type-doc Accept header, returning the RAW
bytes (peer_types owns the term_codec decode, so the wire format lives
in one place — the route encodes, the cache decodes). Cfg carries
type_url / type_url_fn for TypeCid -> base URL resolution.

Tests: next/tests/peer_types_route.sh (13, in-process route dispatch),
next/tests/discovery_type_fetch.sh (9, closure vs a python type-doc
stub, end-to-end through peer_types:lookup_or_fetch).

No regression: http_accept, http_actors, http_get_format,
discovery_fetch all still green. Conformance 771/771.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 15:48:33 +00:00
parent 8d54028c7f
commit 441a895737
4 changed files with 498 additions and 1 deletions

View File

@@ -0,0 +1,118 @@
-module(discovery_type_fetch).
-export([make_fetch_fn/0, make_fetch_fn/1,
fetch/2,
type_doc_url/2,
resolve_type_url/2,
accept_header/0]).
%% Live type-doc fetch for peer_types — host-type federation Step 3,
%% the sibling of discovery_fetch.erl. peer_types:lookup_or_fetch/3
%% calls a Cfg-supplied type_fetch_fn :: fun ((TypeCid, Cfg) -> {ok,
%% Bytes} | {error, _}) on a cache miss; this module produces that
%% closure for live federation. It GETs <base>/types/<cid> with an
%% Accept header that asks for the type-doc format (http_server.erl
%% Step 3) and returns the RAW response bytes — peer_types decodes
%% them via term_codec into the TypeRecord. (This is the one shape
%% difference from discovery_fetch, whose closure returns an already-
%% decoded actor-state: there the cache stores the decoded AS, here
%% peer_types owns the decode so the type-doc wire format lives in one
%% place — the /types/ route encodes, peer_types decodes.)
%%
%% Cfg shape (parallels discovery_fetch's peer URL resolution):
%% {type_url, [{TypeCid, BaseUrl}, ...]}
%% {type_url_fn, fun ((TypeCid) -> {ok, BaseUrl} | not_found)}
%%
%% BaseUrl shape: <<"http://host:port">> (no trailing slash; this
%% module appends the path). TypeCid is the type's CID bytes.
%%
%% Outcomes:
%% 2xx -> {ok, Bytes}
%% non-2xx -> {error, {status, N}}
%% resolver miss -> {error, no_type_url}
%% transport -> {error, Reason}
%% ── Accept header ────────────────────────────────────────────
%% "application/vnd.fed-sx.type-doc" — same MIME http_server's
%% content_type_for(type_doc) emits, so the Accept negotiation routes
%% the served bytes to the term_codec-encoded TypeRecord arm.
accept_header() ->
<<97,112,112,108,105,99,97,116,105,111,110,47,
118,110,100,46,102,101,100,45,115,120,46,
116,121,112,101,45,100,111,99>>.
%% ── public API ───────────────────────────────────────────────
%% make_fetch_fn/0 — the fun/2 peer_types:lookup_or_fetch calls. It
%% reads the type-URL resolver out of the Cfg passed at call time, so
%% the same Cfg threads through peer_types and this closure.
make_fetch_fn() ->
fun (TypeCid, Cfg) ->
case resolve_type_url(TypeCid, Cfg) of
{error, R} -> {error, R};
{ok, BaseUrl} -> fetch(type_doc_url(BaseUrl, TypeCid), Cfg)
end
end.
%% make_fetch_fn/1 — variant that closes over a static Cfg for the
%% resolver while still honouring the call-time Cfg for transport.
%% Lets a caller bake the type_url map once and reuse the closure.
make_fetch_fn(StaticCfg) ->
fun (TypeCid, Cfg) ->
case resolve_type_url(TypeCid, StaticCfg) of
{error, R} -> {error, R};
{ok, BaseUrl} -> fetch(type_doc_url(BaseUrl, TypeCid), Cfg)
end
end.
fetch(Url, _Cfg) ->
AcceptKey = <<97,99,99,101,112,116>>, % "accept"
Headers = [{AcceptKey, accept_header()}],
try httpc:request(Url, get, Headers, <<>>) of
{ok, Status, _H, Body} when Status >= 200, Status < 300 ->
{ok, Body};
{ok, Status, _H, _B} ->
{error, {status, Status}};
Other ->
{error, {bad_response, Other}}
catch
error:Reason -> {error, Reason}
end.
%% type_doc_url/2 — <BaseUrl>/types/<cid>. TypeCid is the cid bytes,
%% appended verbatim as the path segment (matches the "/types/" prefix
%% http_server.erl registers).
type_doc_url(BaseUrl, TypeCid) when is_binary(TypeCid) ->
%% "/types/" — 7 bytes
Prefix = <<47,116,121,112,101,115,47>>,
<<BaseUrl/binary, Prefix/binary, TypeCid/binary>>.
%% resolve_type_url/2 — map a TypeCid to its serving node's base URL.
%% type_url_fn (a 1-arity closure) takes precedence over the static
%% type_url proplist; absent both -> {error, no_type_url}.
resolve_type_url(TypeCid, Cfg) ->
case field(type_url_fn, Cfg) of
Fn when is_function(Fn, 1) ->
case Fn(TypeCid) of
{ok, BaseUrl} -> {ok, BaseUrl};
_ -> {error, no_type_url}
end;
_ ->
case field(type_url, Cfg) of
nil -> {error, no_type_url};
Map ->
case find_keyed(TypeCid, Map) of
{ok, BaseUrl} -> {ok, BaseUrl};
_ -> {error, no_type_url}
end
end
end.
%% ── helpers ──────────────────────────────────────────────────
field(K, [{K, V} | _]) -> V;
field(K, [_ | Rest]) -> field(K, Rest);
field(_, []) -> nil.
find_keyed(_, []) -> {error, not_found};
find_keyed(K, [{K, V} | _]) -> {ok, V};
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).

View File

@@ -4,6 +4,7 @@
welcome_body/0, capabilities_body/0,
capabilities_path/0,
match_prefix/2, actors_prefix/0, actor_doc_response/1,
types_prefix/0, type_doc_response_for/2,
artifacts_prefix/0, artifact_response/1,
projections_list_path/0, projections_prefix/0,
projections_list_response/0, projection_response/1,
@@ -156,7 +157,12 @@ dispatch(<<71, 69, 84>>, Path, F, Cfg) ->
{ok, Name} when byte_size(Name) > 0 ->
projection_response_for(Name, F);
_ ->
not_found_response()
case match_prefix(types_prefix(), Path) of
{ok, Cid} when byte_size(Cid) > 0 ->
type_doc_response_for(Cid, Cfg);
_ ->
not_found_response()
end
end
end
end;
@@ -289,6 +295,10 @@ artifact_response(Cid) ->
Body = <<Pre/binary, Cid/binary, 10>>,
ok_response(Body).
%% "/types/" — 7 bytes: 47 116 121 112 101 115 47 (host-type fed Step 3)
types_prefix() ->
<<47,116,121,112,101,115,47>>.
%% "/projections" — 12 bytes (no trailing slash; the list endpoint)
projections_list_path() ->
<<47,112,114,111,106,101,99,116,105,111,110,115>>.
@@ -488,9 +498,20 @@ actor_doc_prefix() ->
118,110,100,46,102,101,100,45,115,120,46,
97,99,116,111,114,45,100,111,99>>.
%% "application/vnd.fed-sx.type-doc" — 31 bytes (host-type fed Step 3).
%% Distinct from actor-doc: the body is a term_codec-encoded
%% TypeRecord (peer_types cache entry), not a peer-actor-state.
type_doc_prefix() ->
<<97,112,112,108,105,99,97,116,105,111,110,47,
118,110,100,46,102,101,100,45,115,120,46,
116,121,112,101,45,100,111,99>>.
accept_format(nil) -> text;
accept_format(<<>>) -> text;
accept_format(V) when is_binary(V) ->
case match_prefix(type_doc_prefix(), V) of
{ok, _} -> type_doc;
_ ->
case match_prefix(actor_doc_prefix(), V) of
{ok, _} -> actor_doc;
_ ->
@@ -510,6 +531,7 @@ accept_format(V) when is_binary(V) ->
end
end
end
end
end;
accept_format(_) -> text.
@@ -586,6 +608,11 @@ content_type_for(actor_doc) ->
<<97,112,112,108,105,99,97,116,105,111,110,47,
118,110,100,46,102,101,100,45,115,120,46,
97,99,116,111,114,45,100,111,99>>;
%% "application/vnd.fed-sx.type-doc" — 31 bytes (host-type fed Step 3).
content_type_for(type_doc) ->
<<97,112,112,108,105,99,97,116,105,111,110,47,
118,110,100,46,102,101,100,45,115,120,46,
116,121,112,101,45,100,111,99>>;
content_type_for(_) ->
content_type_for(text).
@@ -714,6 +741,42 @@ kernel_actor_state(_Kernel, Id) ->
_ -> nil
end.
%% ── host-type fed Step 3: GET /types/<cid> ──────────────────────
%%
%% Serves a TypeRecord the node has cached (its own published types or
%% types fetched from peers) so a federated peer running
%% discovery_type_fetch can decode it directly into the shape
%% peer_types + the object-schema pipeline stage consume. The wire
%% body is term_codec:encode(TypeRecord) under the
%% application/vnd.fed-sx.type-doc content type; a cache miss is a 404.
%%
%% Cid is the path segment after "/types/" (the type's CID bytes). Cfg
%% carries `{peer_types, peer_types}` to opt the route into the cache —
%% absent (or the gen_server down) short-circuits to 404, matching the
%% kernel_actor_state guard for the actor-doc route. This port can't
%% dispatch `Mod:Fun` on a variable module, so the registered
%% `peer_types` atom is hardcoded; the Cfg field flags "no cache wired".
type_doc_response_for(Cid, Cfg) ->
case type_record_for(Cfg, Cid) of
nil -> not_found_response();
TR -> ok_response(term_codec:encode(TR), type_doc)
end.
type_record_for(Cfg, Cid) ->
case field(peer_types, Cfg) of
nil -> nil;
_ ->
case erlang:whereis(peer_types) of
undefined -> nil;
_ ->
case peer_types:lookup(Cid) of
{ok, TR} -> TR;
_ -> nil
end
end
end.
%% ── Step 4a: per-actor sub-resource stubs ──────────────────────
%% Per design §16.1 each actor has /outbox /inbox /followers
%% /following routes. v1 returns text-stub bodies so route resolution