Skip to content

Commit c6a3995

Browse files
authored
Add request_to_file to HTTP contract for streaming downloads to disk (#169)
Add a new `request_to_file` callback to `hex_http` that streams the HTTP response body directly to a file, avoiding loading the full body into memory. Add `get_tarball_to_file/4` and `get_docs_to_file/4` to `hex_repo` that use the new callback to download tarballs and docs directly to disk.
1 parent 34a2952 commit c6a3995

5 files changed

Lines changed: 183 additions & 58 deletions

File tree

src/hex_http.erl

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
%% @doc
22
%% HTTP contract.
33
-module(hex_http).
4-
-export([request/5]).
4+
-export([request/5, request_to_file/6]).
55
-ifdef(TEST).
66
-export([user_agent/1]).
77
-endif.
@@ -21,26 +21,31 @@
2121
{ok, {status(), headers(), binary()}}
2222
| {error, term()}.
2323

24+
-callback request_to_file(
25+
method(), URI :: binary(), headers(), body(), file:name_all(), adapter_config()
26+
) ->
27+
{ok, {status(), headers()}} | {error, term()}.
28+
2429
-spec request(hex_core:config(), method(), URI :: binary(), headers(), body()) ->
2530
{ok, {status(), headers(), binary()}} | {error, term()}.
2631
request(Config, Method, URI, Headers, Body) when is_binary(URI) and is_map(Headers) ->
27-
{Adapter, AdapterConfig} =
28-
case maps:get(http_adapter, Config, {hex_http_httpc, #{}}) of
29-
{Adapter0, AdapterConfig0} ->
30-
{Adapter0, AdapterConfig0};
31-
%% TODO: remove in v0.9
32-
Adapter0 when is_atom(Adapter0) ->
33-
AdapterConfig0 = maps:get(http_adapter_config, Config, #{}),
34-
io:format(
35-
"[hex_http] setting #{http_adapter => Module, http_adapter_config => Map} "
36-
"is deprecated in favour of #{http_adapter => {Module, Map}}~n"
37-
),
38-
{Adapter0, AdapterConfig0}
39-
end,
32+
{Adapter, AdapterConfig} = adapter(Config),
4033
UserAgentFragment = maps:get(http_user_agent_fragment, Config),
4134
Headers2 = put_new(<<"user-agent">>, user_agent(UserAgentFragment), Headers),
4235
Adapter:request(Method, URI, Headers2, Body, AdapterConfig).
4336

37+
-spec request_to_file(
38+
hex_core:config(), method(), URI :: binary(), headers(), body(), file:name_all()
39+
) ->
40+
{ok, {status(), headers()}} | {error, term()}.
41+
request_to_file(Config, Method, URI, Headers, Body, Filename) when
42+
is_binary(URI) and is_map(Headers)
43+
->
44+
{Adapter, AdapterConfig} = adapter(Config),
45+
UserAgentFragment = maps:get(http_user_agent_fragment, Config),
46+
Headers2 = put_new(<<"user-agent">>, user_agent(UserAgentFragment), Headers),
47+
Adapter:request_to_file(Method, URI, Headers2, Body, Filename, AdapterConfig).
48+
4449
%% @private
4550
user_agent(UserAgentFragment) ->
4651
OTPRelease = erlang:system_info(otp_release),
@@ -52,6 +57,21 @@ user_agent(UserAgentFragment) ->
5257
%% Internal functions
5358
%%====================================================================
5459

60+
%% @private
61+
adapter(Config) ->
62+
case maps:get(http_adapter, Config, {hex_http_httpc, #{}}) of
63+
{Adapter, AdapterConfig} ->
64+
{Adapter, AdapterConfig};
65+
%% TODO: remove in v0.9
66+
Adapter when is_atom(Adapter) ->
67+
AdapterConfig = maps:get(http_adapter_config, Config, #{}),
68+
io:format(
69+
"[hex_http] setting #{http_adapter => Module, http_adapter_config => Map} "
70+
"is deprecated in favour of #{http_adapter => {Module, Map}}~n"
71+
),
72+
{Adapter, AdapterConfig}
73+
end.
74+
5575
%% @private
5676
put_new(Key, Value, Map) ->
5777
case maps:find(Key, Map) of

src/hex_http_httpc.erl

Lines changed: 63 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,53 +12,15 @@
1212

1313
-module(hex_http_httpc).
1414
-behaviour(hex_http).
15-
-export([request/5]).
15+
-export([request/5, request_to_file/6]).
1616

1717
%%====================================================================
1818
%% API functions
1919
%%====================================================================
2020

2121
request(Method, URI, ReqHeaders, Body, AdapterConfig) when is_binary(URI) ->
2222
Profile = maps:get(profile, AdapterConfig, default),
23-
HTTPOptions0 = maps:get(http_options, AdapterConfig, []),
24-
25-
HTTPS =
26-
case URI of
27-
<<"https", _/binary>> -> true;
28-
_ -> false
29-
end,
30-
SSLOpts0 = proplists:get_value(ssl, HTTPOptions0),
31-
32-
HTTPOptions =
33-
if
34-
HTTPS == true andalso SSLOpts0 == undefined ->
35-
%% Add safe defaults if possible.
36-
try
37-
[
38-
{ssl, [
39-
{verify, verify_peer},
40-
{cacerts, public_key:cacerts_get()},
41-
{depth, 3},
42-
{customize_hostname_check, [
43-
{match_fun, public_key:pkix_verify_hostname_match_fun(https)}
44-
]}
45-
]}
46-
| HTTPOptions0
47-
]
48-
catch
49-
_:_ ->
50-
io:format(
51-
"[hex_http_httpc] using default ssl options which are insecure.~n"
52-
"Configure your adapter with: "
53-
"{hex_http_httpc, #{http_options => [{ssl, SslOpts}]}}~n"
54-
"or upgrade Erlang/OTP to OTP-25 or later.~n"
55-
),
56-
HTTPOptions0
57-
end;
58-
true ->
59-
HTTPOptions0
60-
end,
61-
23+
HTTPOptions = http_options(URI, AdapterConfig),
6224
Request = build_request(URI, ReqHeaders, Body),
6325
case httpc:request(Method, Request, HTTPOptions, [{body_format, binary}], Profile) of
6426
{ok, {{_, StatusCode, _}, RespHeaders, RespBody}} ->
@@ -68,10 +30,71 @@ request(Method, URI, ReqHeaders, Body, AdapterConfig) when is_binary(URI) ->
6830
{error, Reason}
6931
end.
7032

33+
request_to_file(Method, URI, ReqHeaders, Body, Filename, AdapterConfig) when is_binary(URI) ->
34+
Profile = maps:get(profile, AdapterConfig, default),
35+
HTTPOptions = http_options(URI, AdapterConfig),
36+
Request = build_request(URI, ReqHeaders, Body),
37+
case
38+
httpc:request(
39+
Method,
40+
Request,
41+
HTTPOptions,
42+
[{stream, unicode:characters_to_list(Filename)}],
43+
Profile
44+
)
45+
of
46+
{ok, saved_to_file} ->
47+
{ok, {200, #{}}};
48+
{ok, {{_, StatusCode, _}, RespHeaders, _RespBody}} ->
49+
RespHeaders2 = load_headers(RespHeaders),
50+
{ok, {StatusCode, RespHeaders2}};
51+
{error, Reason} ->
52+
{error, Reason}
53+
end.
54+
7155
%%====================================================================
7256
%% Internal functions
7357
%%====================================================================
7458

59+
%% @private
60+
http_options(URI, AdapterConfig) ->
61+
HTTPOptions0 = maps:get(http_options, AdapterConfig, []),
62+
63+
HTTPS =
64+
case URI of
65+
<<"https", _/binary>> -> true;
66+
_ -> false
67+
end,
68+
SSLOpts0 = proplists:get_value(ssl, HTTPOptions0),
69+
70+
if
71+
HTTPS == true andalso SSLOpts0 == undefined ->
72+
try
73+
[
74+
{ssl, [
75+
{verify, verify_peer},
76+
{cacerts, public_key:cacerts_get()},
77+
{depth, 3},
78+
{customize_hostname_check, [
79+
{match_fun, public_key:pkix_verify_hostname_match_fun(https)}
80+
]}
81+
]}
82+
| HTTPOptions0
83+
]
84+
catch
85+
_:_ ->
86+
io:format(
87+
"[hex_http_httpc] using default ssl options which are insecure.~n"
88+
"Configure your adapter with: "
89+
"{hex_http_httpc, #{http_options => [{ssl, SslOpts}]}}~n"
90+
"or upgrade Erlang/OTP to OTP-25 or later.~n"
91+
),
92+
HTTPOptions0
93+
end;
94+
true ->
95+
HTTPOptions0
96+
end.
97+
7598
%% @private
7699
build_request(URI, ReqHeaders, Body) ->
77100
build_request2(binary_to_list(URI), dump_headers(ReqHeaders), Body).

src/hex_repo.erl

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
get_versions/1,
77
get_package/2,
88
get_tarball/3,
9+
get_tarball_to_file/4,
910
get_docs/3,
11+
get_docs_to_file/4,
1012
get_public_key/1,
1113
get_hex_installs/1
1214
]).
@@ -91,7 +93,7 @@ get_package(Config, Name) when is_binary(Name) and is_map(Config) ->
9193
%%
9294
%% ```
9395
%% > {ok, {200, _, Tarball}} = hex_repo:get_tarball(hex_core:default_config(), <<"package1">>, <<"1.0.0">>),
94-
%% > {ok, #{metadata := Metadata}} = hex_tarball:unpack(Tarball, memory).
96+
%% > {ok, #{metadata := Metadata}} = hex_tarball:unpack(Tarball, "/tmp/package").
9597
%% '''
9698
%% @end
9799
get_tarball(Config, Name, Version) ->
@@ -104,15 +106,35 @@ get_tarball(Config, Name, Version) ->
104106
Other
105107
end.
106108

109+
%% @doc
110+
%% Gets tarball from the repository and writes it to a file.
111+
%%
112+
%% Examples:
113+
%%
114+
%% ```
115+
%% > {ok, {200, _}} = hex_repo:get_tarball_to_file(hex_core:default_config(), <<"package1">>, <<"1.0.0">>, "/tmp/package.tar"),
116+
%% > {ok, #{metadata := Metadata}} = hex_tarball:unpack({file, "/tmp/package.tar"}, "/tmp/package").
117+
%% '''
118+
%% @end
119+
get_tarball_to_file(Config, Name, Version, Filename) ->
120+
ReqHeaders = make_headers(Config),
121+
122+
case get_to_file(Config, tarball_url(Config, Name, Version), ReqHeaders, Filename) of
123+
{ok, {200, RespHeaders}} ->
124+
{ok, {200, RespHeaders}};
125+
Other ->
126+
Other
127+
end.
128+
107129
%% @doc
108130
%% Gets docs tarball from the repository.
109131
%%
110132
%% Examples:
111133
%%
112134
%% ```
113135
%% > {ok, {200, _, Docs}} = hex_repo:get_docs(hex_core:default_config(), <<"package1">>, <<"1.0.0">>),
114-
%% > hex_tarball:unpack_docs(Docs, memory)
115-
%% {ok, [{"index.html", <<"<!doctype>">>}, ...]}
136+
%% > hex_tarball:unpack_docs(Docs, "/tmp/docs")
137+
%% ok
116138
%% '''
117139
get_docs(Config, Name, Version) ->
118140
ReqHeaders = make_headers(Config),
@@ -124,6 +146,25 @@ get_docs(Config, Name, Version) ->
124146
Other
125147
end.
126148

149+
%% @doc
150+
%% Gets docs tarball from the repository and writes it to a file.
151+
%%
152+
%% Examples:
153+
%%
154+
%% ```
155+
%% > {ok, {200, _}} = hex_repo:get_docs_to_file(hex_core:default_config(), <<"package1">>, <<"1.0.0">>, "/tmp/docs.tar.gz"),
156+
%% > ok = hex_tarball:unpack_docs({file, "/tmp/docs.tar.gz"}, "/tmp/docs").
157+
%% '''
158+
get_docs_to_file(Config, Name, Version, Filename) ->
159+
ReqHeaders = make_headers(Config),
160+
161+
case get_to_file(Config, docs_url(Config, Name, Version), ReqHeaders, Filename) of
162+
{ok, {200, RespHeaders}} ->
163+
{ok, {200, RespHeaders}};
164+
Other ->
165+
Other
166+
end.
167+
127168
%% @doc
128169
%% Gets the public key from the repository.
129170
%%
@@ -173,6 +214,10 @@ get_hex_installs(Config) ->
173214
get(Config, URI, Headers) ->
174215
hex_http:request(Config, get, URI, Headers, undefined).
175216

217+
%% @private
218+
get_to_file(Config, URI, Headers, Filename) ->
219+
hex_http:request_to_file(Config, get, URI, Headers, undefined, Filename).
220+
176221
%% @private
177222
get_protobuf(Config, Path, Decoder) ->
178223
PublicKey = maps:get(repo_public_key, Config),

test/hex_repo_SUITE.erl

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ all() ->
2424
get_versions_test,
2525
get_package_test,
2626
get_tarball_test,
27+
get_tarball_to_file_test,
2728
get_docs_test,
29+
get_docs_to_file_test,
2830
get_hex_installs_test,
2931
get_public_key_test,
3032
repo_org_not_set
@@ -69,6 +71,18 @@ get_tarball_test(_Config) ->
6971
{ok, {403, _, _}} = hex_repo:get_tarball(?CONFIG, <<"ecto">>, <<"9.9.9">>),
7072
ok.
7173

74+
get_tarball_to_file_test(Config) ->
75+
PrivDir = proplists:get_value(priv_dir, Config),
76+
Filename = filename:join(PrivDir, "ecto-1.0.0.tar"),
77+
{ok, {200, #{<<"etag">> := _}}} = hex_repo:get_tarball_to_file(
78+
?CONFIG, <<"ecto">>, <<"1.0.0">>, Filename
79+
),
80+
{ok, Tarball} = file:read_file(Filename),
81+
{ok, _} = hex_tarball:unpack(Tarball, memory),
82+
83+
{ok, {403, _}} = hex_repo:get_tarball_to_file(?CONFIG, <<"ecto">>, <<"9.9.9">>, Filename),
84+
ok.
85+
7286
get_docs_test(_Config) ->
7387
{ok, {200, #{<<"etag">> := ETag}, Docs}} = hex_repo:get_docs(?CONFIG, <<"ecto">>, <<"1.0.0">>),
7488
{ok, _} = hex_tarball:unpack_docs(Docs, memory),
@@ -80,6 +94,18 @@ get_docs_test(_Config) ->
8094
{ok, {403, _, _}} = hex_repo:get_docs(?CONFIG, <<"ecto">>, <<"9.9.9">>),
8195
ok.
8296

97+
get_docs_to_file_test(Config) ->
98+
PrivDir = proplists:get_value(priv_dir, Config),
99+
Filename = filename:join(PrivDir, "ecto-1.0.0-docs.tar.gz"),
100+
{ok, {200, #{<<"etag">> := _}}} = hex_repo:get_docs_to_file(
101+
?CONFIG, <<"ecto">>, <<"1.0.0">>, Filename
102+
),
103+
{ok, Docs} = file:read_file(Filename),
104+
{ok, _} = hex_tarball:unpack_docs(Docs, memory),
105+
106+
{ok, {403, _}} = hex_repo:get_docs_to_file(?CONFIG, <<"ecto">>, <<"9.9.9">>, Filename),
107+
ok.
108+
83109
get_hex_installs_test(_Config) ->
84110
{ok, {200, _, CSV}} = hex_repo:get_hex_installs(?CONFIG),
85111
?assert(is_binary(CSV)),

test/support/hex_http_test.erl

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
-module(hex_http_test).
22
-behaviour(hex_http).
3-
-export([request/5]).
3+
-export([request/5, request_to_file/6]).
44
-define(TEST_REPO_URL, "https://repo.test").
55
-define(TEST_API_URL, "https://api.test").
66
-define(PRIVATE_KEY, ct:get_config({ssl_certs, test_priv})).
@@ -13,6 +13,17 @@
1313
request(Method, URI, Headers, Body, _Options) when is_binary(URI) and is_map(Headers) ->
1414
fixture(Method, URI, Headers, Body).
1515

16+
request_to_file(Method, URI, Headers, Body, Filename, Options) ->
17+
case request(Method, URI, Headers, Body, Options) of
18+
{ok, {200, RespHeaders, RespBody}} ->
19+
ok = file:write_file(Filename, RespBody),
20+
{ok, {200, RespHeaders}};
21+
{ok, {StatusCode, RespHeaders, _RespBody}} ->
22+
{ok, {StatusCode, RespHeaders}};
23+
{error, _} = Error ->
24+
Error
25+
end.
26+
1627
%%====================================================================
1728
%% Internal functions
1829
%%====================================================================

0 commit comments

Comments
 (0)