diff --git a/lib/archethic/contracts/interpreter/ast_helper.ex b/lib/archethic/contracts/interpreter/ast_helper.ex index b2915ada5..ddb2978bd 100644 --- a/lib/archethic/contracts/interpreter/ast_helper.ex +++ b/lib/archethic/contracts/interpreter/ast_helper.ex @@ -32,6 +32,24 @@ defmodule Archethic.Contracts.Interpreter.ASTHelper do def is_keyword_list?(_), do: false + @doc """ + Return wether the given ast is a bool + + iex> ast = quote do: true + iex> ASTHelper.is_boolean?(ast) + true + + iex> ast = quote do: false + iex> ASTHelper.is_boolean?(ast) + true + + iex> ast = quote do: %{"sum" => 1, "product" => 10} + iex> ASTHelper.is_boolean?(ast) + false + """ + @spec is_boolean?(Macro.t()) :: boolean() + def is_boolean?(arg), do: is_boolean(arg) + @doc """ Return wether the given ast is a map diff --git a/lib/archethic/contracts/interpreter/library/common/http.ex b/lib/archethic/contracts/interpreter/library/common/http.ex index 760394af1..f20fb826b 100644 --- a/lib/archethic/contracts/interpreter/library/common/http.ex +++ b/lib/archethic/contracts/interpreter/library/common/http.ex @@ -12,7 +12,9 @@ defmodule Archethic.Contracts.Interpreter.Library.Common.Http do @callback request(String.t(), String.t()) :: map() @callback request(String.t(), String.t(), map()) :: map() @callback request(String.t(), String.t(), map(), String.t() | nil) :: map() + @callback request(String.t(), String.t(), map(), String.t() | nil, boolean()) :: map() @callback request_many(list(map())) :: list(map()) + @callback request_many(list(map()), boolean()) :: list(map()) def check_types(:request, [first]) do binary_or_variable_or_function?(first) @@ -30,10 +32,19 @@ defmodule Archethic.Contracts.Interpreter.Library.Common.Http do check_types(:request, [first, second, third]) && binary_or_variable_or_function?(fourth) end + def check_types(:request, [first, second, third, fourth, fifth]) do + check_types(:request, [first, second, third, fourth]) && + boolean_or_variable_or_function?(fifth) + end + def check_types(:request_many, [first]) do list_or_variable_or_function?(first) end + def check_types(:request_many, [first, second]) do + check_types(:request_many, [first]) && boolean_or_variable_or_function?(second) + end + def check_types(_, _), do: false defp binary_or_variable_or_function?(arg) do @@ -47,4 +58,8 @@ defmodule Archethic.Contracts.Interpreter.Library.Common.Http do defp map_or_variable_or_function?(arg) do AST.is_map?(arg) || AST.is_variable_or_function_call?(arg) end + + defp boolean_or_variable_or_function?(arg) do + AST.is_boolean?(arg) || AST.is_variable_or_function_call?(arg) + end end diff --git a/lib/archethic/contracts/interpreter/library/common/http_impl.ex b/lib/archethic/contracts/interpreter/library/common/http_impl.ex index 240e26214..f424fe6bb 100644 --- a/lib/archethic/contracts/interpreter/library/common/http_impl.ex +++ b/lib/archethic/contracts/interpreter/library/common/http_impl.ex @@ -30,36 +30,77 @@ defmodule Archethic.Contracts.Interpreter.Library.Common.HttpImpl do @tag [:io] @impl Http - def request(uri, method \\ "GET", headers \\ %{}, body \\ nil) + def request(uri, method \\ "GET", headers \\ %{}, body \\ nil, throw_on_error \\ true) - def request(url, method, headers, body) do - request = %{"url" => url, "method" => method, "headers" => headers, "body" => body} - - with :ok <- validate_multiple_calls(), - task <- do_request(request), - results <- await_tasks_result([request], [task]), - {:ok, result} <- List.first(results) do - result - else - error -> raise Library.Error, message: format_error_message(error) - end + def request(url, method, headers, body, throw_on_error) do + [%{"url" => url, "method" => method, "headers" => headers, "body" => body}] + |> request_many(throw_on_error) + |> List.first() end @tag [:io] @impl Http - def request_many(requests) do + def request_many(requests, throw_on_err \\ true) + + def request_many(requests, true) do with :ok <- validate_multiple_calls(), :ok <- validate_nb_requests(requests), requests <- set_request_default(requests), tasks <- Enum.map(requests, &do_request/1), results <- await_tasks_result(requests, tasks), - {:ok, results} <- validate_results(results) do + {:ok, results} <- validate_results(results, true) do results else error -> raise Library.Error, message: format_error_message(error) end end + def request_many(requests, false) do + case validate_multiple_calls() do + {:error, :multiple_calls} -> + Enum.map(requests, fn _ -> %{"status" => -4005} end) + + :ok -> + {requests_to_handle, requests_not_handled} = Enum.split(requests, 5) + + tasks = + requests_to_handle + |> set_request_default() + |> Enum.map(&do_request/1) + + {:ok, results} = + requests_to_handle + |> await_tasks_result(tasks) + |> validate_results(false) + + transform_results( + results ++ Enum.map(requests_not_handled, &{:error, :max_nb_requests, &1}) + ) + end + end + + defp transform_results(results) do + Enum.map(results, fn + {:ok, result} -> + result + + {:error, :max_nb_requests, _} -> + %{"status" => -4003} + + {:error, :threshold_reached, _} -> + %{"status" => -4002} + + {:error, :timeout, _} -> + %{"status" => -4001} + + {:error, :not_supported_scheme, _} -> + %{"status" => -4004} + + {:error, _, _} -> + %{"status" => -4000} + end) + end + defp validate_multiple_calls() do case Process.get(:smart_contract_http_request_called) do true -> @@ -209,37 +250,66 @@ defmodule Archethic.Contracts.Interpreter.Library.Common.HttpImpl do end) end - defp validate_results(results) do + defp validate_results(results, true) do # count the number of bytes to be able to send a error too large # this is sub optimal because miners might still download threshold N times before returning the error # TODO: improve this results |> Enum.reduce_while({:ok, 0, []}, fn - {:ok, result}, {:ok, total_bytes, results} -> + {:ok, result}, {:ok, total_bytes, acc} -> bytes = result |> Map.get("body", "") |> byte_size() new_total_bytes = total_bytes + bytes if new_total_bytes > @threshold do {:halt, {:error, :threshold_reached, %{}}} else - {:cont, {:ok, new_total_bytes, [result | results]}} + {:cont, {:ok, new_total_bytes, [result | acc]}} end error, _acc -> {:halt, error} end) |> then(fn - {:ok, _, results} -> {:ok, Enum.reverse(results)} + {:ok, _, acc} -> {:ok, Enum.reverse(acc)} error -> error end) end + defp validate_results(results, false) do + # count the number of bytes to be able to send a error too large + # this is sub optimal because miners might still download threshold N times before returning the error + # TODO: improve this + results + |> Enum.reduce_while({0, []}, fn + {:ok, result}, {total_bytes, acc} -> + bytes = result |> Map.get("body", "") |> byte_size() + new_total_bytes = total_bytes + bytes + + if new_total_bytes > @threshold do + {:halt, {:error, :threshold_reached, %{}}} + else + {:cont, {new_total_bytes, [{:ok, result} | acc]}} + end + + error, {total_bytes, acc} -> + {:cont, {total_bytes, [error | acc]}} + end) + |> then(fn + {:error, :threshold_reached, %{}} -> + # when threshold_reached we apply this error to all requests + {:ok, Enum.map(results, fn _ -> {:error, :threshold_reached, %{}} end)} + + {_bytes, acc} -> + {:ok, Enum.reverse(acc)} + end) + end + # -------------- # defp format_error_message({:error, :multiple_calls}), do: "Http module got called more than once" defp format_error_message({:error, :max_nb_requests}), - do: "Http.request_many/1 was called with too many requests" + do: "Http.request_many was called with too many requests" defp format_error_message({:error, :invalid_url, %{"url" => url}}), do: "Http module received invalid url, got #{inspect(url)}" diff --git a/lib/archethic/mining/smart_contract_validation.ex b/lib/archethic/mining/smart_contract_validation.ex index 45607d393..c215eac69 100644 --- a/lib/archethic/mining/smart_contract_validation.ex +++ b/lib/archethic/mining/smart_contract_validation.ex @@ -138,6 +138,12 @@ defmodule Archethic.Mining.SmartContractValidation do {:error, Error.new(:invalid_recipients_execution, data)} end + defp format_error_status({:error, :timeout}, data) do + data = data |> Map.put("message", "Failed to validate call due to timeout") + + {:error, Error.new(:invalid_recipients_execution, data)} + end + defp format_error_status( {:error, :invalid_execution, %Failure{user_friendly_error: message, data: failure_data}}, data diff --git a/test/archethic/contracts/interpreter/library/common/http_impl_test.exs b/test/archethic/contracts/interpreter/library/common/http_impl_test.exs index 2b245630f..8d625414f 100644 --- a/test/archethic/contracts/interpreter/library/common/http_impl_test.exs +++ b/test/archethic/contracts/interpreter/library/common/http_impl_test.exs @@ -36,7 +36,7 @@ defmodule Archethic.Contracts.Interpreter.Library.Common.HttpImplTest do # ---------------------------------------- describe "request/4 common behavior" do test "should raise if domain does not exist" do - assert_raise Library.Error, fn -> HttpImpl.request("https://localhost.local", "GET") end + assert_raise Library.Error, fn -> HttpImpl.request("https://non-existing.domain", "GET") end end test "should return a 404 if page does not exist" do @@ -107,6 +107,36 @@ defmodule Archethic.Contracts.Interpreter.Library.Common.HttpImplTest do end end + describe "request/5" do + test "should return a status -4001 for timeout" do + assert %{"status" => -4001} = + HttpImpl.request("https://127.0.0.1:8081/very-slow", "GET", %{}, nil, false) + end + + test "should return a status -4004 for non https" do + assert %{"status" => -4004} = + HttpImpl.request("http://127.0.0.1:8081/", "GET", %{}, nil, false) + end + + test "should return a status -4002 for too large" do + assert %{"status" => -4002} = + HttpImpl.request("https://127.0.0.1:8081/data?kbytes=300", "GET", %{}, nil, false) + end + + test "should return a status -4005 if it's already called once" do + assert %{"status" => 200} = + HttpImpl.request("https://127.0.0.1:8081", "GET", %{}, nil, false) + + assert %{"status" => -4005} = + HttpImpl.request("https://127.0.0.1:8081", "GET", %{}, nil, false) + end + + test "should return a -4000 if the domain is inexistant" do + assert %{"status" => -4000} = + HttpImpl.request("https://non-existing.domain", "GET", %{}, nil, false) + end + end + describe "request_many/1" do test "should return an empty list if it receives an empty list" do assert [] = HttpImpl.request_many([]) @@ -147,7 +177,7 @@ defmodule Archethic.Contracts.Interpreter.Library.Common.HttpImplTest do assert_raise Library.Error, fn -> HttpImpl.request_many([ %{"url" => "https://127.0.0.1:8081", "method" => "GET"}, - %{"url" => "https://localhost.local", "method" => "GET"} + %{"url" => "https://non-existing.domain", "method" => "GET"} ]) end @@ -190,4 +220,99 @@ defmodule Archethic.Contracts.Interpreter.Library.Common.HttpImplTest do end end end + + describe "request_many/2" do + test "should return a status -4001 for timeout" do + assert [ + %{"status" => 200}, + %{"status" => -4001} + ] = + HttpImpl.request_many( + [ + %{"url" => "https://127.0.0.1:8081", "method" => "GET"}, + %{"url" => "https://127.0.0.1:8081/very-slow", "method" => "GET"} + ], + false + ) + end + + test "should return a status -4004 for non https" do + assert [ + %{"status" => 200}, + %{"status" => -4004} + ] = + HttpImpl.request_many( + [ + %{"url" => "https://127.0.0.1:8081", "method" => "GET"}, + %{"url" => "http://127.0.0.1", "method" => "GET"} + ], + false + ) + end + + test "should return a status -4003 for too many urls" do + assert [ + %{"status" => 200}, + %{"status" => 200}, + %{"status" => 200}, + %{"status" => 200}, + %{"status" => 200}, + %{"status" => -4003} + ] = + HttpImpl.request_many( + [ + %{"url" => "https://127.0.0.1:8081", "method" => "GET"}, + %{"url" => "https://127.0.0.1:8081", "method" => "GET"}, + %{"url" => "https://127.0.0.1:8081", "method" => "GET"}, + %{"url" => "https://127.0.0.1:8081", "method" => "GET"}, + %{"url" => "https://127.0.0.1:8081", "method" => "GET"}, + %{"url" => "https://127.0.0.1:8081", "method" => "GET"} + ], + false + ) + end + + test "should return a status -4002 for too large" do + assert [ + %{"status" => -4002}, + %{"status" => -4002} + ] = + HttpImpl.request_many( + [ + %{"url" => "https://127.0.0.1:8081/data?kbytes=200", "method" => "GET"}, + %{"url" => "https://127.0.0.1:8081/data?kbytes=200", "method" => "GET"} + ], + false + ) + end + + test "should return a status -4005 if it's already called once" do + assert [%{"status" => 200}] = + HttpImpl.request_many( + [%{"url" => "https://127.0.0.1:8081", "method" => "GET"}], + false + ) + + assert [%{"status" => -4005}, %{"status" => -4005}, %{"status" => -4005}] = + HttpImpl.request_many( + [ + %{"url" => "https://127.0.0.1:8081", "method" => "GET"}, + %{"url" => "https://127.0.0.1:8081", "method" => "GET"}, + %{"url" => "https://127.0.0.1:8081", "method" => "GET"} + ], + false + ) + end + + test "should return a -4000 if the domain is inexistant" do + assert [%{"status" => 200}, %{"status" => -4000}] = + HttpImpl.request_many( + [ + %{"url" => "https://127.0.0.1:8081", "method" => "GET"}, + %{"url" => "https://non-existing.domain", "method" => "GET"} + ], + false + ) + end + end end