Skip to content

Commit 3bcb29a

Browse files
sliiserphilss
andcommitted
Add support for headers and dynamic base URL (#65)
The :base_url attribute now accepts static headers or a custom function that returns a URL and headers. This allows us to fetch NIFs from more complicated sources, rather than just public GitHub releases. Co-authored-by: Philip Sampaio <[email protected]>
1 parent a5cd67e commit 3bcb29a

File tree

3 files changed

+205
-33
lines changed

3 files changed

+205
-33
lines changed

lib/rustler_precompiled.ex

Lines changed: 73 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,20 @@ defmodule RustlerPrecompiled do
2424
2525
* `:crate` - The name of Rust crate if different from the `:otp_app`. This is optional.
2626
27-
* `:base_url` - A valid URL that is used as base path for the NIF file.
27+
* `:base_url` - Location where to find the NIFs from. This should be one of the following:
28+
29+
* A URL to a directory containing the NIFs. The name of the NIF will be appended to it
30+
and a GET request will be made. Works well with public GitHub releases.
31+
32+
* A tuple of `{URL, headers}`. The headers should be a list of key-value pairs.
33+
This is useful when the NIFs are hosted in a private server.
34+
35+
* A tuple of `{module, function}` where the `function` is an atom representing the function
36+
name in that module. It's expected a function of arity 1, where the NIF file name is given,
37+
and it should return a URL or a tuple of `{URL, headers}`.
38+
This should be used for all cases not covered by the above.
39+
For example when multiple requests have to be made, like when using a private GitHub release
40+
through the GitHub API, or when the URLs don't resemble a simple directory.
2841
2942
* `:version` - The version of precompiled assets (it is part of the NIF filename).
3043
@@ -262,13 +275,19 @@ defmodule RustlerPrecompiled do
262275

263276
@native_dir "priv/native"
264277

278+
@doc deprecated: "Use available_nifs/1 instead"
279+
def available_nif_urls(nif_module) when is_atom(nif_module) do
280+
available_nifs(nif_module)
281+
|> Enum.map(fn {_lib_name, {url, _headers}} -> url end)
282+
end
283+
265284
@doc """
266-
Returns URLs for NIFs based on its module name.
285+
Returns URLs for NIFs based on its module name as a list of tuples: `[{lib_name, {url, headers}}]`.
267286
268287
The module name is the one that defined the NIF and this information
269288
is stored in a metadata file.
270289
"""
271-
def available_nif_urls(nif_module) when is_atom(nif_module) do
290+
def available_nifs(nif_module) when is_atom(nif_module) do
272291
nif_module
273292
|> metadata_file()
274293
|> read_map_from_file()
@@ -286,6 +305,17 @@ defmodule RustlerPrecompiled do
286305

287306
@doc false
288307
def nif_urls_from_metadata(metadata) when is_map(metadata) do
308+
case(nifs_from_metadata(metadata)) do
309+
{:ok, nifs} ->
310+
{:ok, Enum.map(nifs, fn {_lib_name, {url, _headers}} -> url end)}
311+
312+
{:error, wrong_meta} ->
313+
{:error, wrong_meta}
314+
end
315+
end
316+
317+
@doc false
318+
def nifs_from_metadata(metadata) when is_map(metadata) do
289319
case metadata do
290320
%{
291321
targets: targets,
@@ -320,22 +350,26 @@ defmodule RustlerPrecompiled do
320350
variants = Map.fetch!(variants, target_triple)
321351

322352
for variant <- variants do
323-
tar_gz_file_url(
324-
base_url,
325-
lib_name_with_ext(target_triple, lib_name <> "--" <> Atom.to_string(variant))
326-
)
353+
lib_name = lib_name_with_ext(target_triple, lib_name <> "--" <> Atom.to_string(variant))
354+
{lib_name, tar_gz_file_url(base_url, lib_name)}
327355
end
328356
end
329357

330358
defp maybe_variants_tar_gz_urls(_, _, _, _), do: []
331359

360+
@doc deprecated: "Use current_target_nifs/1 instead"
361+
def current_target_nif_urls(nif_module) when is_atom(nif_module) do
362+
current_target_nifs(nif_module)
363+
|> Enum.map(fn {_lib_name, {url, _headers}} -> url end)
364+
end
365+
332366
@doc """
333-
Returns the file URLs to be downloaded for current target.
367+
Returns the file URLs to be downloaded for current target as a list of tuples: `[{lib_name, {url, headers}}]`.
334368
335369
It is in the plural because a target may have some variants for it.
336370
It receives the NIF module.
337371
"""
338-
def current_target_nif_urls(nif_module) when is_atom(nif_module) do
372+
def current_target_nifs(nif_module) when is_atom(nif_module) do
339373
metadata =
340374
nif_module
341375
|> metadata_file()
@@ -362,9 +396,10 @@ defmodule RustlerPrecompiled do
362396

363397
defp tar_gz_urls(base_url, basename, version, nif_version, target_triple, variants) do
364398
lib_name = lib_name(basename, version, nif_version, target_triple)
399+
lib_name_with_ext = lib_name_with_ext(target_triple, lib_name)
365400

366401
[
367-
tar_gz_file_url(base_url, lib_name_with_ext(target_triple, lib_name))
402+
{lib_name_with_ext, tar_gz_file_url(base_url, lib_name_with_ext(target_triple, lib_name))}
368403
| maybe_variants_tar_gz_urls(variants, base_url, target_triple, lib_name)
369404
]
370405
end
@@ -615,7 +650,7 @@ defmodule RustlerPrecompiled do
615650

616651
# `cache_base_dir` is a "private" option used only in tests.
617652
cache_dir = cache_dir(config.base_cache_dir, "precompiled_nifs")
618-
cached_tar_gz = Path.join(cache_dir, "#{file_name}.tar.gz")
653+
cached_tar_gz = Path.join(cache_dir, file_name)
619654

620655
{:ok,
621656
Map.merge(basic_metadata, %{
@@ -840,21 +875,34 @@ defmodule RustlerPrecompiled do
840875
"so"
841876
end
842877

843-
"#{lib_name}.#{ext}"
878+
"#{lib_name}.#{ext}.tar.gz"
844879
end
845880

846-
defp tar_gz_file_url(base_url, file_name) do
881+
defp tar_gz_file_url({module, function_name}, file_name)
882+
when is_atom(module) and is_atom(function_name) do
883+
apply(module, function_name, [file_name])
884+
end
885+
886+
defp tar_gz_file_url({base_url, request_headers}, file_name) do
847887
uri = URI.parse(base_url)
848888

849889
uri =
850890
Map.update!(uri, :path, fn path ->
851-
Path.join(path || "", "#{file_name}.tar.gz")
891+
Path.join(path || "", file_name)
852892
end)
853893

854-
to_string(uri)
894+
{to_string(uri), request_headers}
855895
end
856896

857-
defp download_nif_artifact(url) do
897+
defp tar_gz_file_url(base_url, file_name) do
898+
tar_gz_file_url({base_url, []}, file_name)
899+
end
900+
901+
defp download_nif_artifact(url) when is_binary(url) do
902+
download_nif_artifact({url, []})
903+
end
904+
905+
defp download_nif_artifact({url, request_headers}) do
858906
url = String.to_charlist(url)
859907
Logger.debug("Downloading NIF from #{url}")
860908

@@ -895,7 +943,10 @@ defmodule RustlerPrecompiled do
895943

896944
options = [body_format: :binary]
897945

898-
case :httpc.request(:get, {url, []}, http_options, options) do
946+
request_headers =
947+
Enum.map(request_headers, fn {k, v} when is_binary(k) -> {String.to_charlist(k), v} end)
948+
949+
case :httpc.request(:get, {url, request_headers}, http_options, options) do
899950
{:ok, {{_, 200, _}, _headers, body}} ->
900951
{:ok, body}
901952

@@ -912,16 +963,17 @@ defmodule RustlerPrecompiled do
912963
attempts = max_retries(options)
913964

914965
download_results =
915-
for url <- urls, do: {url, with_retry(fn -> download_nif_artifact(url) end, attempts)}
966+
for {lib_name, url} <- urls,
967+
do: {lib_name, with_retry(fn -> download_nif_artifact(url) end, attempts)}
916968

917969
cache_dir = cache_dir("precompiled_nifs")
918970
:ok = File.mkdir_p(cache_dir)
919971

920972
Enum.flat_map(download_results, fn result ->
921-
with {:download, {url, download_result}} <- {:download, result},
973+
with {:download, {lib_name, download_result}} <- {:download, result},
922974
{:download_result, {:ok, body}} <- {:download_result, download_result},
923975
hash <- :crypto.hash(@checksum_algo, body),
924-
path <- Path.join(cache_dir, basename_from_url(url)),
976+
path <- Path.join(cache_dir, lib_name),
925977
{:file, :ok} <- {:file, File.write(path, body)} do
926978
checksum = Base.encode16(hash, case: :lower)
927979

@@ -931,7 +983,7 @@ defmodule RustlerPrecompiled do
931983

932984
[
933985
%{
934-
url: url,
986+
lib_name: lib_name,
935987
path: path,
936988
checksum: checksum,
937989
checksum_algo: @checksum_algo
@@ -985,14 +1037,6 @@ defmodule RustlerPrecompiled do
9851037
end)
9861038
end
9871039

988-
defp basename_from_url(url) do
989-
uri = URI.parse(url)
990-
991-
uri.path
992-
|> String.split("/")
993-
|> List.last()
994-
end
995-
9961040
defp read_map_from_file(file) do
9971041
with {:ok, contents} <- File.read(file),
9981042
{%{} = contents, _} <- Code.eval_string(contents) do

lib/rustler_precompiled/config.ex

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,16 +83,34 @@ defmodule RustlerPrecompiled.Config do
8383

8484
defp validate_base_url!(nil), do: raise_for_nil_field_value(:base_url)
8585

86-
defp validate_base_url!(base_url) do
86+
defp validate_base_url!(base_url) when is_binary(base_url) do
87+
validate_base_url!({base_url, []})
88+
end
89+
90+
defp validate_base_url!({base_url, headers}) when is_binary(base_url) and is_list(headers) do
8791
case :uri_string.parse(base_url) do
8892
%{} ->
89-
base_url
93+
if Enum.all?(headers, &match?({key, value} when is_list(key) and is_binary(value), &1)) do
94+
{base_url, headers}
95+
else
96+
raise "`:base_url` for `RustlerPrecompiled` must be a list of `{charlist(),binary()}`"
97+
end
9098

9199
{:error, :invalid_uri, error} ->
92100
raise "`:base_url` for `RustlerPrecompiled` is invalid: #{inspect(to_string(error))}"
93101
end
94102
end
95103

104+
defp validate_base_url!({module, function}) when is_atom(module) and is_atom(function) do
105+
Code.ensure_compiled!(module)
106+
107+
if Kernel.function_exported?(module, function, 1) do
108+
{module, function}
109+
else
110+
raise "`:base_url` for `RustlerPrecompiled` is a function that does not exist: `#{inspect(module)}.#{function}/1`"
111+
end
112+
end
113+
96114
defp validate_list!(nil, option, _valid_values), do: raise_for_nil_field_value(option)
97115

98116
defp validate_list!([_ | _] = values, option, valid_values) do

test/rustler_precompiled_test.exs

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,111 @@ defmodule RustlerPrecompiledTest do
500500
end)
501501
end
502502

503+
@tag :tmp_dir
504+
test "a project downloading precompiled NIFs with custom header", %{
505+
tmp_dir: tmp_dir,
506+
checksum_sample: checksum_sample,
507+
nif_fixtures_dir: nif_fixtures_dir
508+
} do
509+
bypass = Bypass.open()
510+
511+
in_tmp(tmp_dir, fn ->
512+
File.write!("checksum-Elixir.RustlerPrecompilationExample.Native.exs", checksum_sample)
513+
514+
Bypass.expect_once(bypass, fn conn ->
515+
file_name = List.last(conn.path_info)
516+
file = File.read!(Path.join([nif_fixtures_dir, "precompiled_nifs", file_name]))
517+
518+
if Plug.Conn.get_req_header(conn, "authorization") == ["Token 123"] do
519+
Plug.Conn.resp(conn, 200, file)
520+
else
521+
Plug.Conn.resp(conn, 401, "Unauthorized")
522+
end
523+
end)
524+
525+
result =
526+
capture_log(fn ->
527+
config = %RustlerPrecompiled.Config{
528+
otp_app: :rustler_precompiled,
529+
module: RustlerPrecompilationExample.Native,
530+
base_cache_dir: tmp_dir,
531+
base_url:
532+
{"http://localhost:#{bypass.port}/download", [{"authorization", "Token 123"}]},
533+
version: "0.2.0",
534+
crate: "example",
535+
targets: @available_targets,
536+
nif_versions: @default_nif_versions
537+
}
538+
539+
{:ok, metadata} = RustlerPrecompiled.build_metadata(config)
540+
541+
assert {:ok, result} = RustlerPrecompiled.download_or_reuse_nif_file(config, metadata)
542+
543+
assert result.load?
544+
assert {:rustler_precompiled, path} = result.load_from
545+
546+
assert path =~ "priv/native"
547+
assert path =~ "example-v0.2.0-nif"
548+
end)
549+
550+
assert result =~ "Downloading"
551+
assert result =~ "http://localhost:#{bypass.port}/download"
552+
assert result =~ "NIF cached at"
553+
end)
554+
end
555+
556+
@tag :tmp_dir
557+
test "a project downloading precompiled NIFs with custom handler", %{
558+
tmp_dir: tmp_dir,
559+
checksum_sample: checksum_sample,
560+
nif_fixtures_dir: nif_fixtures_dir
561+
} do
562+
bypass = Bypass.open(port: 1234)
563+
564+
in_tmp(tmp_dir, fn ->
565+
File.write!("checksum-Elixir.RustlerPrecompilationExample.Native.exs", checksum_sample)
566+
567+
Bypass.expect_once(bypass, fn conn ->
568+
%{"file_name" => file_name} = URI.decode_query(conn.query_string)
569+
file = File.read!(Path.join([nif_fixtures_dir, "precompiled_nifs", file_name]))
570+
571+
if Plug.Conn.get_req_header(conn, "authorization") == ["Token 123"] do
572+
Plug.Conn.resp(conn, 200, file)
573+
else
574+
Plug.Conn.resp(conn, 401, "Unauthorized")
575+
end
576+
end)
577+
578+
result =
579+
capture_log(fn ->
580+
config = %RustlerPrecompiled.Config{
581+
otp_app: :rustler_precompiled,
582+
module: RustlerPrecompilationExample.Native,
583+
base_cache_dir: tmp_dir,
584+
base_url: {__MODULE__, :url_with_headers},
585+
version: "0.2.0",
586+
crate: "example",
587+
targets: @available_targets,
588+
nif_versions: @default_nif_versions
589+
}
590+
591+
{:ok, metadata} = RustlerPrecompiled.build_metadata(config)
592+
593+
assert {:ok, result} = RustlerPrecompiled.download_or_reuse_nif_file(config, metadata)
594+
595+
assert result.load?
596+
assert {:rustler_precompiled, path} = result.load_from
597+
598+
assert path =~ "priv/native"
599+
assert path =~ "example-v0.2.0-nif"
600+
end)
601+
602+
assert result =~ "Downloading"
603+
assert result =~ "http://localhost:#{bypass.port}/download"
604+
assert result =~ "NIF cached at"
605+
end)
606+
end
607+
503608
@tag :tmp_dir
504609
test "a project downloading precompiled NIFs with retry", %{
505610
tmp_dir: tmp_dir,
@@ -793,7 +898,7 @@ defmodule RustlerPrecompiledTest do
793898
# We need this guard because not every one is running the tests in the same OS/Arch.
794899
if metadata.lib_name =~ "x86_64-unknown-linux-gnu" do
795900
assert String.ends_with?(metadata.lib_name, "--old_glibc")
796-
assert String.ends_with?(metadata.file_name, "--old_glibc.so")
901+
assert String.ends_with?(metadata.file_name, "--old_glibc.so.tar.gz")
797902
end
798903
end
799904

@@ -821,7 +926,7 @@ defmodule RustlerPrecompiledTest do
821926

822927
if metadata.lib_name =~ "x86_64-unknown-linux-gnu" do
823928
assert String.ends_with?(metadata.lib_name, "--legacy_cpus")
824-
assert String.ends_with?(metadata.file_name, "--legacy_cpus.so")
929+
assert String.ends_with?(metadata.file_name, "--legacy_cpus.so.tar.gz")
825930
end
826931
end
827932
end
@@ -955,4 +1060,9 @@ defmodule RustlerPrecompiledTest do
9551060
|> Base.encode64()
9561061
|> binary_part(0, len)
9571062
end
1063+
1064+
def url_with_headers(file_name) do
1065+
{"http://localhost:1234/download?file_name=#{file_name}&foo=bar",
1066+
[{"authorization", "Token 123"}]}
1067+
end
9581068
end

0 commit comments

Comments
 (0)