-module(roadrunner_http2_request).
-moduledoc false.
%% Build a roadrunner request map from an HTTP/2 HEADERS block
%% (decoded HPACK header list) per RFC 9113 §8.3.
%%
%% The request shape is the same `roadrunner_req:request()` map
%% that HTTP/1.1 produces — pseudo-headers (`:method`, `:scheme`,
%% `:authority`, `:path`) get normalized into the existing `method`
%% / `scheme` / `target` / regular-header fields so handler code
%% (and `roadrunner_req` accessors) doesn't care which protocol
%% served the request.
%%
%% ## Validation (RFC 9113 §8.1.2)
%%
%% - Exactly one each of `:method`, `:scheme`, `:path` is required;
%% CONNECT requests omit `:scheme`/`:path` and require
%% `:authority` (we accept the simpler "GET-style" request shape
%% here; CONNECT and Extended CONNECT for WebSocket-over-h2
%% support arrive later).
%% - Pseudo-headers MUST appear before regular headers; mixing is
%% a `protocol_error`.
%% - Pseudo-headers other than the four defined are rejected.
%% - `:path` MUST NOT be empty (an h2 client should send `/` for
%% the origin form).
%% - Header field names MUST be lowercase — already enforced by
%% `roadrunner_http2_hpack:decode/2`.
%% - `Connection`-specific headers MUST NOT appear (RFC 9113
%% §8.2.2). Rejected.
-export([from_headers/3]).
-export_type([build_error/0, request_context/0]).
-type build_error() ::
missing_pseudo_header
| duplicate_pseudo_header
| unknown_pseudo_header
| pseudo_after_regular
| empty_path
| connection_specific_header.
-type request_context() :: #{
peer := {inet:ip_address(), inet:port_number()} | undefined,
scheme := http | https,
request_id := binary(),
listener_name := atom()
}.
-doc """
Build a request map from a decoded HPACK header list. `RequestContext`
carries the per-connection bits the HTTP/1 conn already has —
peer, scheme (from the TLS tag), listener_name, request_id.
The returned map is `roadrunner_req:request()` shape with
`version => {2, 0}`, `target` set to the `:path` pseudo-header
value, and `method` set to `:method`. The `:authority`
pseudo-header is forwarded as a `host` header so handlers that
read it via `roadrunner_req:header/2` still work.
`Body` is the iolist of accumulated DATA-frame payload chunks (or `<<>>`
for header-only requests). Stored on the request map as `iodata()`;
handlers requiring a flat binary call `iolist_to_binary/1` themselves.
""".
-spec from_headers(roadrunner_http:headers(), iodata(), request_context()) ->
{ok, roadrunner_req:request()} | {error, build_error()}.
from_headers(Headers, Body, RequestContext) ->
maybe
{ok, Pseudo, Regular} ?= partition(Headers),
%% `validate_pseudo` returns the parsed `:scheme` value but we
%% deliberately discard it — the authoritative scheme comes
%% from the conn (`RequestContext.scheme`) since clients can lie
%% about the pseudo-header value.
{ok, Method, _Scheme, Path, Authority} ?= validate_pseudo(Pseudo),
ok ?= check_banned(Regular),
{ok, build(Method, Path, Authority, Regular, Body, RequestContext)}
end.
%% Walk the decoded header list, collecting pseudo-headers (names
%% starting with `:`) into a map keyed by name and regular headers
%% into a list. Body recursion — regular headers cons in front on
%% the way back out so the order matches the wire order.
%% Walk pseudo-headers (names starting with `:`) into a map until
%% the first regular header, then hand off to `partition_regular/1`
%% which body-recurses the rest. Splitting the two phases avoids
%% the prior shape's double recursion (cons-forward AND cons-back
%% on every regular header).
-spec partition(roadrunner_http:headers()) ->
{ok, map(), roadrunner_http:headers()} | {error, build_error()}.
partition(Headers) ->
partition(Headers, #{}).
-spec partition(roadrunner_http:headers(), map()) ->
{ok, map(), roadrunner_http:headers()} | {error, build_error()}.
partition([], Pseudo) ->
{ok, Pseudo, []};
partition([{<<":", _/binary>> = Name, Value} | Rest], Pseudo) ->
case Pseudo of
#{Name := _} ->
{error, duplicate_pseudo_header};
_ when
Name =:= ~":method";
Name =:= ~":scheme";
Name =:= ~":authority";
Name =:= ~":path"
->
partition(Rest, Pseudo#{Name => Value});
_ ->
{error, unknown_pseudo_header}
end;
partition([H | Rest], Pseudo) ->
case partition_regular(Rest) of
{ok, Tail} -> {ok, Pseudo, [H | Tail]};
{error, _} = E -> E
end.
%% Body-recurse the regular-header tail. A pseudo-header arriving
%% here is RFC 9113 §8.1.2.1 PROTOCOL_ERROR.
-spec partition_regular(roadrunner_http:headers()) ->
{ok, roadrunner_http:headers()} | {error, build_error()}.
partition_regular([]) ->
{ok, []};
partition_regular([{<<":", _/binary>>, _} | _]) ->
{error, pseudo_after_regular};
partition_regular([H | Rest]) ->
case partition_regular(Rest) of
{ok, Tail} -> {ok, [H | Tail]};
{error, _} = E -> E
end.
-spec validate_pseudo(map()) ->
{ok, binary(), binary(), binary(), binary() | undefined}
| {error, build_error()}.
validate_pseudo(Pseudo) ->
case Pseudo of
#{
~":method" := Method,
~":scheme" := Scheme,
~":path" := Path
} when Path =/= ~"" ->
Authority = maps:get(~":authority", Pseudo, undefined),
{ok, Method, Scheme, Path, Authority};
#{~":path" := ~""} ->
{error, empty_path};
_ ->
{error, missing_pseudo_header}
end.
%% Function-clause dispatch over the banned set (RFC 9113 §8.2.2)
%% keeps the hot path branch-friendly: the BEAM compiles the
%% literal-binary clauses to a hash/select, no `lists:member`
%% function call per header.
-spec check_banned(roadrunner_http:headers()) ->
ok | {error, connection_specific_header}.
check_banned([]) -> ok;
check_banned([{~"connection", _} | _]) -> {error, connection_specific_header};
check_banned([{~"keep-alive", _} | _]) -> {error, connection_specific_header};
check_banned([{~"proxy-connection", _} | _]) -> {error, connection_specific_header};
check_banned([{~"transfer-encoding", _} | _]) -> {error, connection_specific_header};
check_banned([{~"upgrade", _} | _]) -> {error, connection_specific_header};
check_banned([{~"te", ~"trailers"} | Rest]) -> check_banned(Rest);
check_banned([{~"te", _} | _]) -> {error, connection_specific_header};
check_banned([_ | Rest]) -> check_banned(Rest).
-spec build(
binary(),
binary(),
binary() | undefined,
roadrunner_http:headers(),
binary(),
map()
) -> roadrunner_req:request().
build(Method, Path, Authority, Regular, Body, RequestContext) ->
%% Forward `:authority` as a `host` header so existing h1
%% handler code that reads `Host` still works. (RFC 9113
%% §8.3.1 says an h2 server MUST treat `:authority` like
%% `Host`.)
HeadersWithHost =
case Authority of
undefined -> Regular;
_ -> [{~"host", Authority} | Regular]
end,
%% Caller (`roadrunner_conn_loop_http2:dispatch_stream`)
%% always builds `RequestContext` with all four fields populated, so
%% pattern-matching wins vs. four `maps:get/3` calls.
#{
peer := Peer,
scheme := Scheme,
request_id := RequestId,
listener_name := ListenerName
} = RequestContext,
#{
method => Method,
target => Path,
version => {2, 0},
headers => HeadersWithHost,
body => Body,
bindings => #{},
peer => Peer,
scheme => Scheme,
request_id => RequestId,
listener_name => ListenerName
}.