Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Smart Contracts: Http.request/5 and Http.request_many/2 #1387

Merged
merged 4 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions lib/archethic/contracts/interpreter/ast_helper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions lib/archethic/contracts/interpreter/library/common/http.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
108 changes: 89 additions & 19 deletions lib/archethic/contracts/interpreter/library/common/http_impl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down Expand Up @@ -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)}"
Expand Down
6 changes: 6 additions & 0 deletions lib/archethic/mining/smart_contract_validation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 127 additions & 2 deletions test/archethic/contracts/interpreter/library/common/http_impl_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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([])
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Loading