Skip to content

Commit

Permalink
feat(hb_codec_http): refactor to/1 to work with TABM. Begin from/1 #13
Browse files Browse the repository at this point in the history
  • Loading branch information
TillaTheHun0 committed Jan 6, 2025
1 parent e988a29 commit 68d32be
Showing 1 changed file with 111 additions and 88 deletions.
199 changes: 111 additions & 88 deletions src/hb_codec_http.erl
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@

%%% @doc Maps the native HyperBEAM Message to an "HTTP" message.
%%% Every HyperBEAM Message is mapped to an HTTP multipart message.
%%% The HTTP Message data structure has the following shape:
%%% @doc A codec for the that marshals TABM encoded messages to and from the
%%% "HTTP" message structure.
%%%
%%% The HTTP Message is an Erlang Map with the following shape:
%%% #{
%%% headers => [
%%% {<<"Example-Header">>, <<"Value">>}
%%% ],
%%% body: <<"Some body">>
%%% }
%%%
%%% Every HTTP message is an HTTP multipart message.
%%% See https://datatracker.ietf.org/doc/html/rfc7578
%%%
%%% For each TABM Key:
%%%
%%% For each HyperBEAM Message Key:
%%%
%%% The Key will be ignored if:
%%% The TABM Key will be ignored if:
%%% - The field is private (according to hb_private:is_private/1)
%%% - The field is one of ?REGEN_KEYS
%%%
Expand Down Expand Up @@ -41,40 +44,86 @@

-define(MAX_HEADER_LENGTH, 256).

to(Msg) ->
PublicMsg = hb_private:reset(Msg),
MinimizedMsg = hb_message:minimize(PublicMsg),
NormalizedMsg = hb_message:normalize_keys(MinimizedMsg),
Http = lists:foldl(
%%% @doc Convert an HTTP Message into a TABM.
%%% Any HTTP Structured Field is encoded into it's equivalent TABM encoding.
%%% TODO: special rules for Signature and Signature-Input headers
%% Recursively encode the multipart body into TABM
from(#{ headers := Headers, body := Body }) ->
ContentType = lists:keyfind(<<"Content-Type">>, 1, Headers),
{item, _, Params} = hb_http_structured_fields:item(ContentType),
_Parts = case lists:keyfind(<<"boundary">>, 1, Params) of
false -> [Body];
{_, Boundary} ->
% The first part will always be empty (since the boundary is always placed first
% in the body
[_, P] = binary:split(Body, <<"--", Boundary/binary>>),
% The last part MIGHT be "--" for the terminating boundary.
%
% So we need to check and potentially trim off the last
% element
_TrimmedParts = case lists:last(P) of
<<"--">> ->
lists:sublist(P, length(P) - 1);
_ -> P
end
end,

% TODO: WIP NOT DONE
% Take each part and convert into a HB message
% - headers become fields
% - maybe parse as structured fields?
% - parts become fields (recursively parsed)
% - "inline" part becomes top level "body" field
% - "Signature" & "Signature-Input" are parsed as SF dictionaries and become "Signatures" on HB message

not_implemented.

%%% @doc Convert a TABM into an HTTP Message. The HTTP Message is a simple Erlang Map
%%% that can translated to a given web server Response API
to(Bin) when is_binary(Bin) -> Bin;
to(TABM) ->
% PublicMsg = hb_private:reset(TABM),
% MinimizedMsg = hb_message:minimize(PublicMsg),
% NormalizedMsg = hb_message:normalize_keys(MinimizedMsg),
Http = maps:fold(
fun
({<<"signatures">>, Signatures}, Http) -> signatures_to_http(Http, Signatures);
({<<"body">>, Body}, Http) -> body_to_http(Http, Body);
({Name, Value}, Http) -> field_to_http(Http, {Name, Value}, #{})
% Signatures (note abbr. & case-insensitivity) are mapped according to RFC-9421
(<<"signatures">>, Signatures, Http) -> signatures_to_http(Http, Signatures);
(<<"sigs">>, Signatures, Http) -> signatures_to_http(Http, Signatures);
(<<"sig">>, Signature, Http) -> signatures_to_http(Http, [Signature]);
(<<"Signatures">>, Signatures, Http) -> signatures_to_http(Http, Signatures);
(<<"Sigs">>, Signatures, Http) -> signatures_to_http(Http, Signatures);
(<<"Sig">>, Signature, Http) -> signatures_to_http(Http, [Signature]);

% Body (note case-insensitivity) is mapped into a multipart according to RFC-7578
(<<"body">>, Body, Http) -> body_to_http(Http, Body);
(<<"Body">>, Body, Http) -> body_to_http(Http, Body);

% All other values are encoded as an HTTP Structured Fields.
% non-map/list is encoded as an HTTP Structured Field Item
(Name, Value, Http) when not (is_map(Value) orelse is_list(Value)) ->
{ok, Item} = hb_http_structured_fields:to_item(Value),
field_to_http(Http, {Name, iolist_to_binary(hb_http_structured_fields:item(Item))}, #{});
% Further mapping of lists and maps delegated to field_to_http
(Name, Value, Http) -> field_to_http(Http, {Name, Value}, #{})
end,
#{
headers => [],
body => #{}
},
maps:to_list(NormalizedMsg)
#{ headers => [], body => #{} },
TABM
),
Body = maps:get(body, Http),
NewHttp = case maps:size(Body) of
0 -> maps:put(body, <<>>, Http);
_ ->
?no_prod("What should the Boundary be?"),
Boundary = base64:encode(crypto:strong_rand_bytes(8)),
% Transform body into a binary, delimiting each part,
% with the Boundary
Bin = maps:fold(
% Transform body into a binary, delimiting each part with the Boundary
BodyBin = maps:fold(
fun (_, BodyPart, Acc) ->
<<Acc/binary, "--", Boundary/binary, "\n", BodyPart/binary, "\n">>
end,
<<>>,
Body
),
% TODO: I _think_ this is needed, according to spec
% End the body with a final terminating Boundary
EncodedBody = <<Bin/binary, "--", Boundary/binary, "--">>,
#{
headers => [
{
Expand All @@ -83,7 +132,9 @@ to(Msg) ->
}
| maps:get(headers, Http)
],
body => EncodedBody
% TODO: I _think_ this is needed, according to the spec
% End the body with a final terminating Boundary
body => <<BodyBin/binary, "--", Boundary/binary, "--">>
}
end,
NewHttp.
Expand All @@ -101,7 +152,7 @@ encode_http_msg (#{ headers := SubHeaders, body := SubBody }) ->
% Content-Type: image/png
%
% <body>
<<EncodedHeaders/binary, <<"\n\n">>, SubBody/binary>>.
<<EncodedHeaders/binary, "\n\n", SubBody/binary>>.

signatures_to_http(Http, Signatures) when is_map(Signatures) ->
signatures_to_http(Http, maps:to_list(Signatures));
Expand Down Expand Up @@ -135,59 +186,60 @@ body_to_http(Http, Body) when is_binary(Body) ->
Disposition = <<"Content-Disposition: inline">>,
field_to_http(Http, {<<"body">>, Body}, #{ disposition => Disposition, where => body }).

field_to_http(Http, {Name, {<<"List">>, Value}}, Opts) ->
field_to_http(Http, {Name, Value}, Opts);
field_to_http(Http, {Name, MapOrList}, Opts) when is_map(MapOrList) orelse is_list(MapOrList) ->
{Mapper, Parser} = case MapOrList of
Map when is_map(Map) -> {fun hb_http_structured_fields:to_dictionary/1, fun hb_http_structured_fields:dictionary/1};
List when is_list(List) -> {fun hb_http_structured_fields:to_list/1, fun hb_http_structured_fields:list/1}
end,
MaybeBin = case Mapper(MapOrList) of
MaybeEncoded = case Mapper(MapOrList) of
{ok, Sf} ->
% Check the size of the encoded dictionary, and signal to store
% the map as an Structured Field encoded dictionary in the header
% Check the size of the encoded value, and signal to store
% the as a Structured Field encoded dictionary in the header
%
% Otherwise, we will need to convert the Map into its own HTTP message
% and append as a part of the body in the parent multi-part msg
EncodedSf = Parser(Sf),
case byte_size(EncodedSf) of
Fits when Fits =< ?MAX_HEADER_LENGTH -> EncodedSf;
_ -> undefined
end;
EncodedSf = iolist_to_binary(Parser(Sf)),
Fits = byte_size(EncodedSf) =< ?MAX_HEADER_LENGTH,
{Fits, EncodedSf};
_ -> undefined
end,
?no_prod("What should the name be?"),
NormalizedName = hb_converge:key_to_binary(Name),
case MaybeBin of
Bin when is_binary(Bin) ->
case MaybeEncoded of
{true, Bin} ->
field_to_http(Http, {NormalizedName, Bin}, Opts);
undefined when is_map(MapOrList) ->
% Encode the map as a sub part, to be appended to the body
{false, _} when is_map(MapOrList) ->
SubHttp = to(MapOrList),
EncodedHttpMap = encode_http_msg(SubHttp),
% Append to the serialized field to the parent body, as a part
field_to_http(Http, {Name, EncodedHttpMap}, Opts);
field_to_http(Http, {Name, EncodedHttpMap}, maps:put(where, body, Opts));
% Encode the SF list as a sub part, to be appended to the body
{false, Bin} when is_list(MapOrList) ->
field_to_http(Http, {NormalizedName, Bin}, maps:put(where, body, Opts));
undefined when is_list(MapOrList) ->
?no_prod("how do we further encode a list?"),
?no_prod("how do we encode a list in HTTP message if it cannot be encoded as a structured field?"),
not_implemented
end;
% field_to_http(Http, {Name, List}, Opts) when is_list(List) ->
% {not_implemented, List};
field_to_http(Http, {Name, Value}, Opts) ->

field_to_http(Http, {Name, Value}, Opts) when is_binary(Value) ->
NormalizedName = hb_converge:key_to_binary(Name),
NormalizedValue = hb_converge:key_to_binary(Value),

% Depending on the size of the value, we may need to force
% the value to be encoded into the body.
%
% Otherwise, we place the value according to Opts,
% defaulting to header
DefaultWhere = case byte_size(NormalizedValue) of

% The default location where the value is encoded within the HTTP
% message depends on its size.
%
% So we check whether the size of the value is within the threshold
% to encode as a header, and other default to encoding in the body
DefaultWhere = case byte_size(Value) of
Fits when Fits =< ?MAX_HEADER_LENGTH -> headers;
_ -> maps:get(where, Opts, headers)
end,

case maps:get(where, Opts, DefaultWhere) of
headers ->
Headers = maps:get(headers, Http),
NewHeaders = lists:append(Headers, [{NormalizedName, NormalizedValue}]),
NewHeaders = lists:append(Headers, [{NormalizedName, Value}]),
maps:put(headers, NewHeaders, Http);
% Append the value as a part of the multipart body
%
Expand All @@ -198,52 +250,23 @@ field_to_http(Http, {Name, Value}, Opts) ->
body ->
Body = maps:get(body, Http),
Disposition = maps:get(disposition, Opts, <<"Content-Disposition: form-data; name=", NormalizedName/binary>>),
BodyPart = <<Disposition/binary, "\n\n", NormalizedValue/binary>>,
BodyPart = <<Disposition/binary, "\n\n", Value/binary>>,
NewBody = maps:put(NormalizedName, BodyPart, Body),
maps:put(body, NewBody, Http)
end.

from(#{ headers := Headers, body := Body }) ->
ContentType = lists:keyfind(<<"Content-Type">>, 1, Headers),
{item, _, Params} = hb_http_structured_fields:item(ContentType),
_Parts = case lists:keyfind(<<"boundary">>, 1, Params) of
false -> [Body];
{_, Boundary} ->
% The first part will always be empty (since the boundary is always placed first
% in the body
[_, P] = binary:split(Body, <<"--", Boundary/binary>>),
% The last part MIGHT be "--" for the terminating boundary.
%
% So we need to check and potentially trim off the last
% element
_TrimmedParts = case lists:last(P) of
<<"--">> ->
lists:sublist(P, length(P) - 1);
_ -> P
end
end,
% TODO: WIP NOT DONE
% Take each part and convert into a HB message
% - headers become fields
% - maybe parse as structured fields?
% - parts become fields (recursively parsed)
% - "inline" part becomes top level "body" field
% - "Signature" & "Signature-Input" are parsed as SF dictionaries and become "Signatures" on HB message

not_implemented.

%%% Tests

simple_message_to_http_test() ->
Msg = #{ a => 1, b => 2, priv_c => 3, id => <<"regen_ignore">> },
Http = hb_codec_http:to(Msg),
simple_message_to_test() ->
Http = hb_codec_http:to(Msg = #{ a => 1, b => <<"foo">> }),
erlang:display({foooo, Http}),
?assertEqual(
#{ headers => [{<<"a">>, <<"1">>}, {<<"b">>, <<"2">>}], body => <<>> },
#{ headers => [{<<"a">>, <<"1">>}, {<<"b">>, <<"\"foo\"">>}], body => <<>> },
Http
),
ok.

simple_body_message_to_http_test() ->
simple_body_message_to_test() ->
Html = <<"<html><body>Hello</body></html>">>,
_Msg = #{ "Content-Type" => <<"text/html">>, body => Html },
%Http = hb_codec_http:to(Msg),
Expand Down

0 comments on commit 68d32be

Please sign in to comment.