Why: * We'd like to add support for GraphQL queries to get transactions by address hash. RPC API users could use this instead of the `txlist` RPC API action. Example usage: 1. ``` query($hash: AddressHash!, $first: Int!) { address(hash: $hash) { transactions(first: $first) { edges { node { hash blockNumber } cursor } } } ``` 2. with pagination support via [Relay Cursor Connections](https://facebook.github.io/relay/graphql/connections.htm) ``` query($hash: AddressHash!, $first: Int!, $after: String!) { address(hash: $hash) { transactions(first: $first, after: $after) { pageInfo { hasNextPage hasPreviousPage } edges { node { hash blockNumber } cursor } } } } ``` * Issue link: n/a This change addresses the need by: * Renaming `BlockScoutWeb.Schema.Query.AddressTest` to `...AddressesTest` for the name to match the query field it is testing. * Adding `:absinthe_relay` dependency to `BlockScoutWeb` app for Absinthe support for the [Relay framework](https://facebook.github.io/relay/graphql/connections.htm). * Editing `BlockScoutWeb.Schema` to use `Absinthe.Relay.Schema`, define a node interface, and define a node field. This was necessary to support relay connections. * Adding an `address` field to `BlockScoutWeb.Schema`, to get a single address by hash. * Adding a `TransactionConnection` field to the `address` object type in `BlockScoutWeb.Schema.Types` * Configuring the `transaction` object type as a node, in `BlockScoutWeb.Schema.Types`. * Adding a resolver function in `BlockScoutWeb.Resolvers.Address`, to get a single address. * Adding a resolver function in `BlockScoutWeb.Resolvers.Transaction`, to get transactions for an address. * Creating `Explorer.GraphQL` for GraphQL API specific Ecto queries. The only function in this module now is `address_to_transactions_query/1`. This function is necessary for `Absinthe.Relay.Connection.from_query/4` to load transactions in the new transaction resolver mentioned above. * Editing RPC API docs to include reference to the GraphQL "transactions" field.pull/1100/head
parent
d3b0824f18
commit
195f7d39ee
@ -0,0 +1,185 @@ |
||||
defmodule BlockScoutWeb.Schema.Query.AddressesTest do |
||||
use BlockScoutWeb.ConnCase |
||||
|
||||
describe "addresses field" do |
||||
test "with valid argument 'hashes', returns all expected fields", %{conn: conn} do |
||||
address = insert(:address, fetched_coin_balance: 100) |
||||
|
||||
query = """ |
||||
query ($hashes: [AddressHash!]!) { |
||||
addresses(hashes: $hashes) { |
||||
hash |
||||
fetched_coin_balance |
||||
fetched_coin_balance_block_number |
||||
contract_code |
||||
} |
||||
} |
||||
""" |
||||
|
||||
variables = %{"hashes" => to_string(address.hash)} |
||||
|
||||
conn = get(conn, "/graphql", query: query, variables: variables) |
||||
|
||||
assert json_response(conn, 200) == %{ |
||||
"data" => %{ |
||||
"addresses" => [ |
||||
%{ |
||||
"hash" => to_string(address.hash), |
||||
"fetched_coin_balance" => to_string(address.fetched_coin_balance.value), |
||||
"fetched_coin_balance_block_number" => address.fetched_coin_balance_block_number, |
||||
"contract_code" => nil |
||||
} |
||||
] |
||||
} |
||||
} |
||||
end |
||||
|
||||
test "with contract address, `contract_code` is serialized as expected", %{conn: conn} do |
||||
address = insert(:contract_address, fetched_coin_balance: 100) |
||||
|
||||
query = """ |
||||
query ($hashes: [AddressHash!]!) { |
||||
addresses(hashes: $hashes) { |
||||
contract_code |
||||
} |
||||
} |
||||
""" |
||||
|
||||
variables = %{"hashes" => to_string(address.hash)} |
||||
|
||||
conn = get(conn, "/graphql", query: query, variables: variables) |
||||
|
||||
assert json_response(conn, 200) == %{ |
||||
"data" => %{ |
||||
"addresses" => [ |
||||
%{ |
||||
"contract_code" => to_string(address.contract_code) |
||||
} |
||||
] |
||||
} |
||||
} |
||||
end |
||||
|
||||
test "smart_contract returns all expected fields", %{conn: conn} do |
||||
address = insert(:address, fetched_coin_balance: 100) |
||||
smart_contract = insert(:smart_contract, address_hash: address.hash) |
||||
|
||||
query = """ |
||||
query ($hashes: [AddressHash!]!) { |
||||
addresses(hashes: $hashes) { |
||||
fetched_coin_balance |
||||
smart_contract { |
||||
name |
||||
compiler_version |
||||
optimization |
||||
contract_source_code |
||||
abi |
||||
address_hash |
||||
} |
||||
} |
||||
} |
||||
""" |
||||
|
||||
variables = %{"hashes" => to_string(address.hash)} |
||||
|
||||
conn = get(conn, "/graphql", query: query, variables: variables) |
||||
|
||||
assert json_response(conn, 200) == %{ |
||||
"data" => %{ |
||||
"addresses" => [ |
||||
%{ |
||||
"fetched_coin_balance" => to_string(address.fetched_coin_balance.value), |
||||
"smart_contract" => %{ |
||||
"name" => smart_contract.name, |
||||
"compiler_version" => smart_contract.compiler_version, |
||||
"optimization" => smart_contract.optimization, |
||||
"contract_source_code" => smart_contract.contract_source_code, |
||||
"abi" => Jason.encode!(smart_contract.abi), |
||||
"address_hash" => to_string(address.hash) |
||||
} |
||||
} |
||||
] |
||||
} |
||||
} |
||||
end |
||||
|
||||
test "errors for non-existent address hashes", %{conn: conn} do |
||||
address = build(:address) |
||||
|
||||
query = """ |
||||
query ($hashes: [AddressHash!]!) { |
||||
addresses(hashes: $hashes) { |
||||
fetched_coin_balance |
||||
} |
||||
} |
||||
""" |
||||
|
||||
variables = %{"hashes" => [to_string(address.hash)]} |
||||
|
||||
conn = get(conn, "/graphql", query: query, variables: variables) |
||||
|
||||
assert %{"errors" => [error]} = json_response(conn, 200) |
||||
assert error["message"] =~ ~s(Addresses not found.) |
||||
end |
||||
|
||||
test "errors if argument 'hashes' is missing", %{conn: conn} do |
||||
query = """ |
||||
query { |
||||
addresses { |
||||
fetched_coin_balance |
||||
} |
||||
} |
||||
""" |
||||
|
||||
variables = %{} |
||||
|
||||
conn = get(conn, "/graphql", query: query, variables: variables) |
||||
|
||||
assert %{"errors" => [error]} = json_response(conn, 200) |
||||
assert error["message"] == ~s(In argument "hashes": Expected type "[AddressHash!]!", found null.) |
||||
end |
||||
|
||||
test "errors if argument 'hashes' is not a list of address hashes", %{conn: conn} do |
||||
query = """ |
||||
query ($hashes: [AddressHash!]!) { |
||||
addresses(hashes: $hashes) { |
||||
fetched_coin_balance |
||||
} |
||||
} |
||||
""" |
||||
|
||||
variables = %{"hashes" => ["someInvalidHash"]} |
||||
|
||||
conn = get(conn, "/graphql", query: query, variables: variables) |
||||
|
||||
assert %{"errors" => [error]} = json_response(conn, 200) |
||||
assert error["message"] =~ ~s(Argument "hashes" has invalid value) |
||||
end |
||||
|
||||
test "correlates complexity to size of 'hashes' argument", %{conn: conn} do |
||||
# max of 50 addresses with four fields of complexity 1 can be fetched |
||||
# per query: |
||||
# 50 * 4 = 200, which is equal to a max complexity of 200 |
||||
hashes = 51 |> build_list(:address) |> Enum.map(&to_string(&1.hash)) |
||||
|
||||
query = """ |
||||
query ($hashes: [AddressHash!]!) { |
||||
addresses(hashes: $hashes) { |
||||
hash |
||||
fetched_coin_balance |
||||
fetched_coin_balance_block_number |
||||
contract_code |
||||
} |
||||
} |
||||
""" |
||||
|
||||
variables = %{"hashes" => hashes} |
||||
|
||||
conn = get(conn, "/graphql", query: query, variables: variables) |
||||
|
||||
assert %{"errors" => [error1, error2]} = json_response(conn, 200) |
||||
assert error1["message"] =~ ~s(Field addresses is too complex) |
||||
assert error2["message"] =~ ~s(Operation is too complex) |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,35 @@ |
||||
defmodule BlockScoutWeb.Schema.Query.NodeTest do |
||||
use BlockScoutWeb.ConnCase |
||||
|
||||
describe "node field" do |
||||
test "with valid argument 'id' for a transaction", %{conn: conn} do |
||||
transaction = insert(:transaction) |
||||
|
||||
query = """ |
||||
query($id: ID!) { |
||||
node(id: $id) { |
||||
... on Transaction { |
||||
id |
||||
hash |
||||
} |
||||
} |
||||
} |
||||
""" |
||||
|
||||
id = Base.encode64("Transaction:#{transaction.hash}") |
||||
|
||||
variables = %{"id" => id} |
||||
|
||||
conn = get(conn, "/graphql", query: query, variables: variables) |
||||
|
||||
assert json_response(conn, 200) == %{ |
||||
"data" => %{ |
||||
"node" => %{ |
||||
"id" => id, |
||||
"hash" => to_string(transaction.hash) |
||||
} |
||||
} |
||||
} |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,22 @@ |
||||
defmodule Explorer.GraphQL do |
||||
@moduledoc """ |
||||
The GraphQL context. |
||||
""" |
||||
|
||||
import Ecto.Query, |
||||
only: [ |
||||
order_by: 3, |
||||
or_where: 3, |
||||
where: 3 |
||||
] |
||||
|
||||
alias Explorer.Chain.{Address, Hash, Transaction} |
||||
|
||||
def address_to_transactions_query(%Address{hash: %Hash{byte_count: unquote(Hash.Address.byte_count())} = address_hash}) do |
||||
Transaction |
||||
|> order_by([transaction], desc: transaction.block_number, desc: transaction.index) |
||||
|> where([transaction], transaction.to_address_hash == ^address_hash) |
||||
|> or_where([transaction], transaction.from_address_hash == ^address_hash) |
||||
|> or_where([transaction], transaction.created_contract_address_hash == ^address_hash) |
||||
end |
||||
end |
@ -0,0 +1,90 @@ |
||||
defmodule Explorer.GraphQLTest do |
||||
use Explorer.DataCase |
||||
|
||||
import Explorer.Factory |
||||
|
||||
alias Explorer.{GraphQL, Repo} |
||||
|
||||
describe "address_to_transactions_query/1" do |
||||
test "with address hash with zero transactions" do |
||||
result = |
||||
:address |
||||
|> insert() |
||||
|> GraphQL.address_to_transactions_query() |
||||
|> Repo.all() |
||||
|
||||
assert result == [] |
||||
end |
||||
|
||||
test "with matching 'to_address_hash'" do |
||||
address = insert(:address) |
||||
transaction = insert(:transaction, to_address: address) |
||||
insert(:transaction) |
||||
|
||||
[found_transaction] = |
||||
address |
||||
|> GraphQL.address_to_transactions_query() |
||||
|> Repo.all() |
||||
|
||||
assert found_transaction.hash == transaction.hash |
||||
end |
||||
|
||||
test "with matching 'from_address_hash'" do |
||||
address = insert(:address) |
||||
transaction = insert(:transaction, from_address: address) |
||||
insert(:transaction) |
||||
|
||||
[found_transaction] = |
||||
address |
||||
|> GraphQL.address_to_transactions_query() |
||||
|> Repo.all() |
||||
|
||||
assert found_transaction.hash == transaction.hash |
||||
end |
||||
|
||||
test "with matching 'created_contract_address_hash'" do |
||||
address = insert(:address) |
||||
transaction = insert(:transaction, created_contract_address: address) |
||||
insert(:transaction) |
||||
|
||||
[found_transaction] = |
||||
address |
||||
|> GraphQL.address_to_transactions_query() |
||||
|> Repo.all() |
||||
|
||||
assert found_transaction.hash == transaction.hash |
||||
end |
||||
|
||||
test "orders by descending block and index" do |
||||
first_block = insert(:block) |
||||
second_block = insert(:block) |
||||
third_block = insert(:block) |
||||
|
||||
address = insert(:address) |
||||
|
||||
3 |
||||
|> insert_list(:transaction, from_address: address) |
||||
|> with_block(second_block) |
||||
|
||||
3 |
||||
|> insert_list(:transaction, from_address: address) |
||||
|> with_block(third_block) |
||||
|
||||
3 |
||||
|> insert_list(:transaction, from_address: address) |
||||
|> with_block(first_block) |
||||
|
||||
found_transactions = |
||||
address |
||||
|> GraphQL.address_to_transactions_query() |
||||
|> Repo.all() |
||||
|
||||
block_number_and_index_order = |
||||
Enum.map(found_transactions, fn transaction -> |
||||
{transaction.block_number, transaction.index} |
||||
end) |
||||
|
||||
assert block_number_and_index_order == Enum.sort(block_number_and_index_order, &(&1 >= &2)) |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue