Skip to content

Commit 2bf9d88

Browse files
committed
Add simple http client
Add simple http client that can be used in different situations, such as when performing updates, inspired to Mint Elixir http client. Signed-off-by: Davide Bettio <[email protected]>
1 parent 6a5c944 commit 2bf9d88

File tree

5 files changed

+241
-0
lines changed

5 files changed

+241
-0
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010

1111
- Fix bug in `erlang:ref_to_list/1`, the unique integer was truncated on some 32-bit architectures
1212

13+
### Added
14+
15+
- Simple http client, that can be used for different use case such as downloading OTA updates
16+
1317
## [0.6.0] - 2024-03-05
1418

1519
### Fixed

examples/erlang/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,4 @@ pack_runnable(code_lock code_lock estdlib eavmlib)
4040
pack_runnable(mqtt_client mqtt_client estdlib eavmlib)
4141
pack_runnable(network_console network_console estdlib eavmlib alisp)
4242
pack_runnable(logging_example logging_example estdlib eavmlib)
43+
pack_runnable(http_client http_client estdlib eavmlib)

examples/erlang/http_client.erl

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
%
2+
% This file is part of AtomVM.
3+
%
4+
% Copyright 2024 Davide Bettio <[email protected]>
5+
%
6+
% Licensed under the Apache License, Version 2.0 (the "License");
7+
% you may not use this file except in compliance with the License.
8+
% You may obtain a copy of the License at
9+
%
10+
% http://www.apache.org/licenses/LICENSE-2.0
11+
%
12+
% Unless required by applicable law or agreed to in writing, software
13+
% distributed under the License is distributed on an "AS IS" BASIS,
14+
% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
% See the License for the specific language governing permissions and
16+
% limitations under the License.
17+
%
18+
% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later
19+
%
20+
21+
-module(http_client).
22+
-export([start/0]).
23+
24+
start() ->
25+
{ok, Conn} = ahttp_client:connect(http, "localhost", 8000, []),
26+
{ok, Conn2, _Ref} = ahttp_client:request(Conn, <<"GET">>, <<"/">>, [], undefined),
27+
loop(Conn2).
28+
29+
loop(Conn) ->
30+
receive
31+
Message ->
32+
case ahttp_client:stream(Conn, Message) of
33+
{ok, _Conn, closed} ->
34+
io:format("Connection closed.~n");
35+
{ok, UpdatedConn, Responses} ->
36+
io:format("Got: ~p~n", [Responses]),
37+
loop(UpdatedConn);
38+
unknown ->
39+
io:format("Unexpected message: ~p~n", [Message])
40+
end
41+
end.

libs/eavmlib/src/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ project(eavmlib)
2323
include(BuildErlang)
2424

2525
set(ERLANG_MODULES
26+
ahttp_client
2627
atomvm
2728
avm_pubsub
2829
console

libs/eavmlib/src/ahttp_client.erl

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
%
2+
% This file is part of AtomVM.
3+
%
4+
% Copyright 2024 Davide Bettio <[email protected]>
5+
%
6+
% Licensed under the Apache License, Version 2.0 (the "License");
7+
% you may not use this file except in compliance with the License.
8+
% You may obtain a copy of the License at
9+
%
10+
% http://www.apache.org/licenses/LICENSE-2.0
11+
%
12+
% Unless required by applicable law or agreed to in writing, software
13+
% distributed under the License is distributed on an "AS IS" BASIS,
14+
% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
% See the License for the specific language governing permissions and
16+
% limitations under the License.
17+
%
18+
% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later
19+
%
20+
21+
-module(ahttp_client).
22+
-export([connect/4, request/5, stream/2]).
23+
24+
-define(DEFAULT_WANTED_HEADERS, [<<"Content-Length">>]).
25+
26+
-record(http_client, {host, socket, parser, ref}).
27+
-record(parser_state, {state, acc, remaining_body_bytes, wanted_headers = ?DEFAULT_WANTED_HEADERS}).
28+
29+
connect(Protocol, Host, Port, Options) when is_binary(Host) ->
30+
connect(Protocol, binary_to_list(Host), Port, Options);
31+
connect(http, Host, Port, _Options) ->
32+
{ok, Sock} = gen_tcp:connect(Host, Port, [binary, {active, true}]),
33+
{ok, #http_client{host = list_to_binary(Host), socket = {gen_tcp, Sock}}}.
34+
35+
request(#http_client{host = Host, socket = TSocket} = Conn, Method, Path, Headers, Body) ->
36+
{BodyBin, MaybeBodyHeaders} = prepare_body(Body, Headers),
37+
HeadersBin = transform_headers(MaybeBodyHeaders),
38+
Data =
39+
<<Method/binary, " ", Path/binary,
40+
" HTTP/1.1\r\n"
41+
"Host: ", Host/binary, "\r\n", HeadersBin/binary, "\r\n", BodyBin/binary>>,
42+
{gen_tcp, TCPSocket} = TSocket,
43+
case gen_tcp:send(TCPSocket, Data) of
44+
ok ->
45+
Ref = make_ref(),
46+
{ok, Conn#http_client{ref = Ref}, Ref};
47+
_ ->
48+
error
49+
end.
50+
51+
prepare_body(undefined, Headers) ->
52+
{<<"">>, Headers};
53+
prepare_body(nil, Headers) ->
54+
{<<"">>, Headers};
55+
prepare_body(Body, Headers) ->
56+
BodyLen = integer_to_binary(byte_size(Body)),
57+
{Body, [{<<"content-length">>, BodyLen} | Headers]}.
58+
59+
transform_headers([]) ->
60+
<<"">>;
61+
transform_headers([{Name, Value} | Tail]) ->
62+
Bin = transform_headers(Tail),
63+
<<Name/binary, ": ", Value/binary, "\r\n", Bin/binary>>.
64+
65+
stream(#http_client{socket = {gen_tcp, TSocket}, parser = Parser} = Conn, {tcp, TSocket, Chunk}) ->
66+
{ok, UpdatedParser, Parsed} = feed_parser(Parser, Chunk),
67+
Responses = make_responses(Parsed, Conn#http_client.ref, []),
68+
{ok, Conn#http_client{parser = UpdatedParser}, Responses};
69+
stream(#http_client{socket = {gen_tcp, TSocket}} = Conn, {tcp_closed, TSocket}) ->
70+
{ok, Conn, closed};
71+
stream(_Conn, _Other) ->
72+
unknown.
73+
74+
make_responses([], _Ref, Acc) ->
75+
Acc;
76+
make_responses([Response | Tail], Ref, Acc) when is_atom(Response) ->
77+
make_responses(Tail, Ref, [{Response, Ref} | Acc]);
78+
make_responses([{Tag, Value} | Tail], Ref, Acc) ->
79+
make_responses(Tail, Ref, [{Tag, Ref, Value} | Acc]).
80+
81+
feed_parser(undefined, Chunk) ->
82+
feed_parser(#parser_state{}, Chunk);
83+
feed_parser(#parser_state{state = body} = Parser, Chunk) ->
84+
consume_bytes(append_chunk(Parser, Chunk), []);
85+
feed_parser(Parser, Chunk) ->
86+
consume_lines(append_chunk(Parser, Chunk), []).
87+
88+
consume_bytes(#parser_state{acc = undefined} = Parser, ParsedAcc) ->
89+
{ok, Parser, ParsedAcc};
90+
consume_bytes(#parser_state{acc = Chunk} = Parser, ParsedAcc) when is_binary(Chunk) ->
91+
ReplacedAccParser = replace_chunk(Parser, undefined),
92+
NewRemBodyBytes = maybe_decrement(
93+
ReplacedAccParser#parser_state.remaining_body_bytes, byte_size(Chunk)
94+
),
95+
{UpdatedParser, Parsed} = maybe_handle_end_of_response(
96+
ReplacedAccParser#parser_state{remaining_body_bytes = NewRemBodyBytes}, [
97+
{data, Chunk} | ParsedAcc
98+
]
99+
),
100+
{ok, UpdatedParser, Parsed}.
101+
102+
consume_lines(Parser, ParsedAcc) ->
103+
case binary:split(Parser#parser_state.acc, <<"\r\n">>) of
104+
[_NotTerminatedLine] ->
105+
{ok, Parser, ParsedAcc};
106+
[Line, Rest] ->
107+
ReplacedAccParser = replace_chunk(Parser, Rest),
108+
case parse_line(ReplacedAccParser, Line) of
109+
{consume_bytes, UpdatedParser} ->
110+
consume_bytes(UpdatedParser, ParsedAcc);
111+
{ok, UpdatedParser} ->
112+
consume_lines(UpdatedParser, ParsedAcc);
113+
{ok, UpdatedParser, Found} ->
114+
consume_lines(UpdatedParser, [Found | ParsedAcc]);
115+
{error, UpdatedParser, NotParsed} ->
116+
{error, UpdatedParser, ParsedAcc, NotParsed}
117+
end
118+
end.
119+
120+
append_chunk(#parser_state{acc = undefined} = Parser, Chunk) ->
121+
Parser#parser_state{acc = Chunk};
122+
append_chunk(#parser_state{acc = Acc} = Parser, Chunk) ->
123+
Parser#parser_state{acc = <<Acc/binary, Chunk/binary>>}.
124+
125+
replace_chunk(Parser, <<>>) ->
126+
Parser#parser_state{acc = undefined};
127+
replace_chunk(Parser, Chunk) ->
128+
Parser#parser_state{acc = Chunk}.
129+
130+
parse_line(
131+
#parser_state{state = undefined} = Parser, <<"HTTP/1.0 ", C:3/binary, " ", _Txt/binary>>
132+
) ->
133+
StatusCode = binary_to_integer(C),
134+
{ok, Parser#parser_state{state = headers}, {status, StatusCode}};
135+
parse_line(
136+
#parser_state{state = undefined} = Parser, <<"HTTP/1.1 ", C:3/binary, " ", _Txt/binary>>
137+
) ->
138+
StatusCode = binary_to_integer(C),
139+
{ok, Parser#parser_state{state = headers}, {status, StatusCode}};
140+
parse_line(#parser_state{state = headers} = Parser, <<>>) ->
141+
{consume_bytes, Parser#parser_state{state = body}};
142+
parse_line(#parser_state{state = headers, wanted_headers = WantedHeaders} = Parser, HeaderLine) ->
143+
case split_header(WantedHeaders, HeaderLine) of
144+
{ok, Name, Value} ->
145+
TrimmedValue = trim_left_spaces(Value, 0),
146+
UpdatedParser =
147+
case Name of
148+
<<"Content-Length">> ->
149+
RemainingLen = binary_to_integer(TrimmedValue),
150+
Parser#parser_state{remaining_body_bytes = RemainingLen};
151+
_ ->
152+
Parser
153+
end,
154+
{ok, UpdatedParser, {header, {Name, TrimmedValue}}};
155+
ignore ->
156+
{ok, Parser};
157+
error ->
158+
{error, Parser, HeaderLine}
159+
end;
160+
parse_line(Parser, Any) ->
161+
{error, Parser, Any}.
162+
163+
trim_left_spaces(Bin, Count) ->
164+
case Bin of
165+
<<_Bin:Count/binary, $\s, _Rest/binary>> ->
166+
trim_left_spaces(Bin, Count + 1);
167+
<<_Bin:Count/binary, NoLeftSpaces/binary>> ->
168+
NoLeftSpaces
169+
end.
170+
171+
maybe_decrement(undefined, _B) ->
172+
undefined;
173+
maybe_decrement(A, B) ->
174+
A - B.
175+
176+
maybe_handle_end_of_response(#parser_state{remaining_body_bytes = 0} = ParserState, AlreadyParsed) ->
177+
{ParserState#parser_state{state = done}, [done | AlreadyParsed]};
178+
maybe_handle_end_of_response(ParserState, AlreadyParsed) ->
179+
{ParserState, AlreadyParsed}.
180+
181+
split_header(all, HeaderLine) ->
182+
case binary:split(HeaderLine, <<":">>) of
183+
[_Invalid, <<>>] -> error;
184+
[Name, Value] -> {ok, Name, Value};
185+
_ -> error
186+
end;
187+
split_header([], _HeaderLine) ->
188+
ignore;
189+
split_header([Name | Tail], HeaderLine) ->
190+
NameLen = byte_size(Name),
191+
case HeaderLine of
192+
<<Name:NameLen/binary, $:, Value/binary>> -> {ok, Name, Value};
193+
_NotMatched -> split_header(Tail, HeaderLine)
194+
end.

0 commit comments

Comments
 (0)