diff --git a/.formatter.exs b/.formatter.exs index 115d6ccc..27ee9115 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,5 @@ [ - import_deps: [:ecto, :phoenix], + import_deps: [:ecto, :phoenix, :absinthe], inputs: [ "{mix,.formatter}.exs", "*.{ex,exs}", diff --git a/config/config.exs b/config/config.exs index ce451bda..397c881e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -14,7 +14,10 @@ config :athena, ecto_repos: [Athena.Repo], generators: [context_app: :athena, binary_id: true] -config :athena, Athena.Repo, migration_primary_key: [id: :uuid, type: :binary_id] +config :athena, Athena.Repo, + migration_primary_key: [id: :uuid, type: :binary_id], + migration_foreign_key: [column: :id, type: :binary_id], + migration_timestamps: [type: :utc_datetime_usec] # Configures the endpoint config :athena, AthenaWeb.Endpoint, diff --git a/lib/athena.ex b/lib/athena.ex index 78ac9c3c..4b33789d 100644 --- a/lib/athena.ex +++ b/lib/athena.ex @@ -7,13 +7,18 @@ defmodule Athena do if it comes from the database, an external API or others. """ + @doc false def model do quote do use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id + @timestamps_opts type: :utc_datetime_usec @type association(type) :: Ecto.Association.NotLoaded.t() | type end diff --git a/lib/athena/inventory/event.ex b/lib/athena/inventory/event.ex index 9b34f613..b8729b75 100644 --- a/lib/athena/inventory/event.ex +++ b/lib/athena/inventory/event.ex @@ -14,8 +14,8 @@ defmodule Athena.Inventory.Event do locations: association([Location.t()]), item_groups: association([ItemGroup.t()]), items: association([Item.t()]), - inserted_at: NaiveDateTime.t(), - updated_at: NaiveDateTime.t() + inserted_at: DateTime.t(), + updated_at: DateTime.t() } schema "events" do diff --git a/lib/athena/inventory/item.ex b/lib/athena/inventory/item.ex index 854b652c..b7575c3e 100644 --- a/lib/athena/inventory/item.ex +++ b/lib/athena/inventory/item.ex @@ -15,8 +15,8 @@ defmodule Athena.Inventory.Item do inverse: boolean, item_group: association(ItemGroup.t()), event: association(Event.t()), - inserted_at: NaiveDateTime.t(), - updated_at: NaiveDateTime.t() + inserted_at: DateTime.t(), + updated_at: DateTime.t() } schema "items" do diff --git a/lib/athena/inventory/item_group.ex b/lib/athena/inventory/item_group.ex index e00db87c..2970bf26 100644 --- a/lib/athena/inventory/item_group.ex +++ b/lib/athena/inventory/item_group.ex @@ -14,8 +14,8 @@ defmodule Athena.Inventory.ItemGroup do event: association(Event.t()), location: association(Location.t()), items: association([Item.t()]), - inserted_at: NaiveDateTime.t(), - updated_at: NaiveDateTime.t() + inserted_at: DateTime.t(), + updated_at: DateTime.t() } schema "item_groups" do diff --git a/lib/athena/inventory/location.ex b/lib/athena/inventory/location.ex index 396402ac..606048b9 100644 --- a/lib/athena/inventory/location.ex +++ b/lib/athena/inventory/location.ex @@ -17,8 +17,8 @@ defmodule Athena.Inventory.Location do items: association([Item.t()]), movements_in: association([Movement.t()]), movements_out: association([Movement.t()]), - inserted_at: NaiveDateTime.t(), - updated_at: NaiveDateTime.t() + inserted_at: DateTime.t(), + updated_at: DateTime.t() } schema "locations" do diff --git a/lib/athena/inventory/movement.ex b/lib/athena/inventory/movement.ex index 391d3e06..967d8837 100644 --- a/lib/athena/inventory/movement.ex +++ b/lib/athena/inventory/movement.ex @@ -17,8 +17,8 @@ defmodule Athena.Inventory.Movement do item_group: association(ItemGroup.t()), source_location: association(Location.t() | nil), destination_location: association(Location.t() | nil), - inserted_at: NaiveDateTime.t(), - updated_at: NaiveDateTime.t() + inserted_at: DateTime.t(), + updated_at: DateTime.t() } schema "movements" do diff --git a/lib/athena_web.ex b/lib/athena_web.ex index 66ac0779..82e2f2dc 100644 --- a/lib/athena_web.ex +++ b/lib/athena_web.ex @@ -121,6 +121,33 @@ defmodule AthenaWeb do end end + @doc false + @spec subschema :: Macro.t() + def subschema do + quote do + use Absinthe.Schema.Notation + use Absinthe.Relay.Schema.Notation, :modern + + import Absinthe.Resolution.Helpers, only: [dataloader: 1] + # import AbsintheErrorPayload.Payload + + alias __MODULE__.Resolver + + alias AthenaWeb.Schema.Dataloader, as: RepoDataLoader + end + end + + @doc false + @spec resolver :: Macro.t() + def resolver do + quote do + import Absinthe.Resolution.Helpers + import Ecto.Query + + alias Athena.Repo + end + end + @doc """ When used, dispatch to the appropriate controller/view/etc. """ diff --git a/lib/athena_web/endpoint.ex b/lib/athena_web/endpoint.ex index e22ef564..4fa3cef4 100644 --- a/lib/athena_web/endpoint.ex +++ b/lib/athena_web/endpoint.ex @@ -43,7 +43,7 @@ defmodule AthenaWeb.Endpoint do plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] plug Plug.Parsers, - parsers: [:urlencoded, :multipart, :json], + parsers: [:urlencoded, :multipart, :json, Absinthe.Plug.Parser], pass: ["*/*"], json_decoder: Phoenix.json_library() diff --git a/lib/athena_web/exception.ex b/lib/athena_web/exception.ex new file mode 100644 index 00000000..55f6d765 --- /dev/null +++ b/lib/athena_web/exception.ex @@ -0,0 +1,22 @@ +defprotocol AthenaWeb.Exception do + @fallback_to_any true + + @spec result(exception :: Exception.t()) :: {:ok, term} | {:error, String.t()} | :unknown + def result(exception) +end + +defimpl AthenaWeb.Exception, for: Any do + @spec result(exception :: Exception.t()) :: {:ok, term} | {:error, String.t()} | :unknown + def result(%{result: result} = _exception), do: result + def result(_exception), do: :unknown +end + +defimpl AthenaWeb.Exception, for: Ecto.NoResultsError do + @spec result(exception :: Exception.t()) :: {:ok, term} | {:error, String.t()} | :unknown + def result(_exception), do: {:ok, nil} +end + +defimpl AthenaWeb.Exception, for: Ecto.Query.CastError do + @spec result(exception :: Exception.t()) :: {:ok, term} | {:error, String.t()} | :unknown + def result(_exception), do: {:ok, nil} +end diff --git a/lib/athena_web/middleware/safe.ex b/lib/athena_web/middleware/safe.ex new file mode 100644 index 00000000..a494946f --- /dev/null +++ b/lib/athena_web/middleware/safe.ex @@ -0,0 +1,85 @@ +defmodule AthenaWeb.Middleware.Safe do + @moduledoc false + + alias AthenaWeb.Endpoint + + require Logger + + @spec add_error_handling(spec :: Absinthe.Middleware.spec()) :: Absinthe.Middleware.spec() + def add_error_handling(spec), do: &(spec |> to_fun(&1, &2) |> exec_safely(&1)) + + # Absinthe Node Error + defp to_fun( + {{Absinthe.Relay.Node, :global_id_resolver}, nil}, + %{state: :resolved} = resolution, + _config + ) do + fn -> resolution end + end + + defp to_fun({{module, function}, config}, resolution, _config) do + fn -> apply(module, function, [resolution, config]) end + end + + defp to_fun({module, config}, resolution, _config) do + fn -> module.call(resolution, config) end + end + + defp to_fun(module, resolution, config) when is_atom(module) do + fn -> module.call(resolution, config) end + end + + defp to_fun(function, resolution, config) when is_function(function, 2) do + fn -> function.(resolution, config) end + end + + defp exec_safely(function, resolution) when is_function(function, 0) do + function.() + catch + kind, reason -> + full_exception = Exception.format(kind, reason, __STACKTRACE__) + result = AthenaWeb.Exception.result(reason) + + result = + case {result, Endpoint.config(:debug_errors, false)} do + {{:ok, _} = result, _} -> + result + + {{:error, error}, true} -> + {:error, + """ + #{inspect(error, pretty: true)} + + DEBUG: + #{full_exception} + """} + + {{:error, error}, false} -> + {:error, error} + + {:unknown, true} -> + Logger.error(""" + Unknown exception catched: + #{full_exception} + """) + + {:error, + """ + unkown error + + DEBUG: + #{full_exception} + """} + + {:unknown, false} -> + Logger.error(""" + Unknown exception catched: + #{full_exception} + """) + + {:error, "unkown error"} + end + + Absinthe.Resolution.put_result(resolution, result) + end +end diff --git a/lib/athena_web/router.ex b/lib/athena_web/router.ex index 5b2d4f9c..18d14b66 100644 --- a/lib/athena_web/router.ex +++ b/lib/athena_web/router.ex @@ -5,6 +5,10 @@ defmodule AthenaWeb.Router do @subresource_actions [:index, :new, :create] + pipeline :api do + plug :accepts, ["json"] + end + pipeline :browser do plug :accepts, ["html"] @@ -82,6 +86,18 @@ defmodule AthenaWeb.Router do get "/", Redirector, to: "/admin/events" end + scope "/api" do + pipe_through [:api] + + forward( + "/", + Absinthe.Plug.GraphiQL, + schema: AthenaWeb.Schema, + socket: AthenaWeb.UserSocket, + interface: :playground + ) + end + defp auth(conn, _opts), do: Plug.BasicAuth.basic_auth(conn, Application.fetch_env!(:athena, Plug.BasicAuth)) end diff --git a/lib/athena_web/schema.ex b/lib/athena_web/schema.ex new file mode 100644 index 00000000..f9a1cdb9 --- /dev/null +++ b/lib/athena_web/schema.ex @@ -0,0 +1,66 @@ +defmodule AthenaWeb.Schema do + @moduledoc """ + Root GraphQL Schema + """ + + use Absinthe.Schema + use Absinthe.Relay.Schema, :modern + + alias AthenaWeb.Middleware.Safe + alias AthenaWeb.Schema.Dataloader, as: RepoDataLoader + alias AthenaWeb.Schema.Resolver + + @impl Absinthe.Schema + @spec context(context :: map) :: map + def context(context), + do: + Map.put( + context, + :loader, + Dataloader.add_source(Dataloader.new(), RepoDataLoader, RepoDataLoader.data()) + ) + + @impl Absinthe.Schema + @spec plugins :: [atom] + def plugins, do: [Absinthe.Middleware.Dataloader | Absinthe.Plugin.defaults()] + + @impl Absinthe.Schema + @spec middleware( + [Absinthe.Middleware.spec(), ...], + Absinthe.Type.Field.t(), + Absinthe.Type.Object.t() + ) :: [Absinthe.Middleware.spec(), ...] + def middleware(middleware, _field, _object), + do: Enum.map(middleware, &Safe.add_error_handling/1) + + import_types Absinthe.Plug.Types + # import_types AbsintheErrorPayload.ValidationMessageTypes + import_types AthenaWeb.Schema.Event + import_types AthenaWeb.Schema.Scalar.Date + import_types AthenaWeb.Schema.Scalar.Datetime + import_types AthenaWeb.Schema.Scalar.Map + import_types AthenaWeb.Schema.Scalar.Ok + import_types AthenaWeb.Schema.Scalar.URI + + node interface do + end + + interface :resource do + field :id, non_null(:id) + field :inserted_at, non_null(:datetime) + field :updated_at, non_null(:datetime) + + interface :node + end + + query do + node field do + resolve(&Resolver.node/2) + end + + import_fields :event_queries + end + + # mutation do + # end +end diff --git a/lib/athena_web/schema/dataloader.ex b/lib/athena_web/schema/dataloader.ex new file mode 100644 index 00000000..f37f67b8 --- /dev/null +++ b/lib/athena_web/schema/dataloader.ex @@ -0,0 +1,10 @@ +defmodule AthenaWeb.Schema.Dataloader do + @moduledoc """ + Absinthe Dataloader + """ + + alias Athena.Repo + + @spec data :: Dataloader.Ecto.t() + def data, do: Dataloader.Ecto.new(Repo) +end diff --git a/lib/athena_web/schema/event.ex b/lib/athena_web/schema/event.ex new file mode 100644 index 00000000..055a6a7b --- /dev/null +++ b/lib/athena_web/schema/event.ex @@ -0,0 +1,33 @@ +defmodule AthenaWeb.Schema.Event do + @moduledoc false + + use AthenaWeb, :subschema + + alias Athena.Inventory.Event + + node object(:event) do + field :name, non_null(:string) + + # connection field :locations, node_type: :location + # connection field :item_groups, node_type: :item_group + # connection field :items, node_type: :item + + field :inserted_at, non_null(:datetime) + field :updated_at, non_null(:datetime) + + is_type_of(&match?(%Event{}, &1)) + + interface :resource + end + + connection(node_type: :event, non_null: true) + + object :event_queries do + @desc "Get Event By ID" + field :event, :event do + arg :id, non_null(:id) + + resolve(&Resolver.event/3) + end + end +end diff --git a/lib/athena_web/schema/event/resolver.ex b/lib/athena_web/schema/event/resolver.ex new file mode 100644 index 00000000..946d1edf --- /dev/null +++ b/lib/athena_web/schema/event/resolver.ex @@ -0,0 +1,9 @@ +defmodule AthenaWeb.Schema.Event.Resolver do + @moduledoc false + + alias Athena.Inventory + + @spec event(parent :: term(), args :: map(), resolution :: Absinthe.Resolution.t()) :: + {:ok, term()} | {:error, term()} + def event(_parent, %{id: id}, _resolution), do: {:ok, Inventory.get_event!(id)} +end diff --git a/lib/athena_web/schema/resolver.ex b/lib/athena_web/schema/resolver.ex new file mode 100644 index 00000000..0d62e001 --- /dev/null +++ b/lib/athena_web/schema/resolver.ex @@ -0,0 +1,9 @@ +defmodule AthenaWeb.Schema.Resolver do + @moduledoc false + + alias Athena.Inventory + + @spec node(id :: map(), resolution :: Absinthe.Resolution.t()) :: + {:ok, term()} | {:error, term()} + def node(%{type: :event, id: id}, _resolution), do: {:ok, Inventory.get_event!(id)} +end diff --git a/lib/athena_web/schema/scalar/date.ex b/lib/athena_web/schema/scalar/date.ex new file mode 100644 index 00000000..0d84cabc --- /dev/null +++ b/lib/athena_web/schema/scalar/date.ex @@ -0,0 +1,35 @@ +defmodule AthenaWeb.Schema.Scalar.Date do + @moduledoc """ + Date Type for API + """ + + use Absinthe.Schema.Notation + + alias Absinthe.Blueprint.Input + + @desc """ + Date Generic Type + + Example: `2019-05-07` + """ + scalar :date do + parse &_parse/1 + serialize &_serialize/1 + end + + @spec _serialize(date :: Date.t()) :: String.t() + defp _serialize(date), do: Date.to_iso8601(date) + + @spec _parse(input :: any) :: {:ok, Date.t()} | :error + defp _parse(%Input.String{value: value}) do + case Date.from_iso8601(value) do + {:ok, date} -> + {:ok, date} + + {:error, _} -> + :error + end + end + + defp _parse(_other), do: :error +end diff --git a/lib/athena_web/schema/scalar/datetime.ex b/lib/athena_web/schema/scalar/datetime.ex new file mode 100644 index 00000000..306e5436 --- /dev/null +++ b/lib/athena_web/schema/scalar/datetime.ex @@ -0,0 +1,35 @@ +defmodule AthenaWeb.Schema.Scalar.Datetime do + @moduledoc """ + DateTime Type for API + """ + + use Absinthe.Schema.Notation + + alias Absinthe.Blueprint.Input + + @desc """ + Date Generic Type + + Example: `2021-05-04 11:44:10.858225Z` + """ + scalar :datetime do + parse &_parse/1 + serialize &_serialize/1 + end + + @spec _serialize(datetime :: DateTime.t()) :: String.t() + defp _serialize(datetime), do: DateTime.to_iso8601(datetime) + + @spec _parse(input :: any) :: {:ok, DateTime.t()} | :error + defp _parse(%Input.String{value: value}) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> + {:ok, datetime} + + {:error, _} -> + :error + end + end + + defp _parse(_other), do: :error +end diff --git a/lib/athena_web/schema/scalar/map.ex b/lib/athena_web/schema/scalar/map.ex new file mode 100644 index 00000000..4e2da3f6 --- /dev/null +++ b/lib/athena_web/schema/scalar/map.ex @@ -0,0 +1,24 @@ +defmodule AthenaWeb.Schema.Scalar.Map do + @moduledoc """ + Map Type for API + """ + + use Absinthe.Schema.Notation + + @desc """ + Map Generic Type + + Example: `{"foo": "bar"}` + """ + scalar :map, open_ended: true do + parse &_parse/1 + serialize &_serialize/1 + end + + @spec _serialize(map :: map()) :: map() + defp _serialize(%{} = map), do: map + + @spec _parse(input :: any) :: {:ok, map()} | :error + defp _parse(%{} = value), do: {:ok, value} + defp _parse(_other), do: :error +end diff --git a/lib/athena_web/schema/scalar/ok.ex b/lib/athena_web/schema/scalar/ok.ex new file mode 100644 index 00000000..ab2280db --- /dev/null +++ b/lib/athena_web/schema/scalar/ok.ex @@ -0,0 +1,13 @@ +defmodule AthenaWeb.Schema.Scalar.Ok do + @moduledoc """ + Ok Enum for API + """ + + use Absinthe.Schema.Notation + + @desc """ + Enum to suggest that action was executed successfully, no details are + provided. + """ + enum(:ok, values: [:ok]) +end diff --git a/lib/athena_web/schema/scalar/uri.ex b/lib/athena_web/schema/scalar/uri.ex new file mode 100644 index 00000000..a14e7069 --- /dev/null +++ b/lib/athena_web/schema/scalar/uri.ex @@ -0,0 +1,34 @@ +defmodule AthenaWeb.Schema.Scalar.URI do + @moduledoc """ + URI Type for API + """ + + use Absinthe.Schema.Notation + + alias Absinthe.Blueprint.Input + + @desc """ + URI Generic Type + + Example: `https://example.com` + """ + scalar :uri do + parse(&_parse/1) + serialize(&_serialize/1) + end + + @spec _serialize(uri :: URI.t()) :: String.t() + defp _serialize(%URI{} = uri), do: URI.to_string(uri) + defp _serialize(uri) when is_binary(uri), do: uri |> URI.parse() |> _serialize + + @spec _parse(input :: any) :: {:ok, URI.t()} | :error + defp _parse(%Input.String{value: value}) do + case URI.parse(value) do + %URI{scheme: nil} -> :error + %URI{scheme: scheme, host: nil} when scheme in ["http", "https"] -> :error + uri -> {:ok, uri} + end + end + + defp _parse(_other), do: :error +end diff --git a/mix.exs b/mix.exs index 06527057..c042c0c9 100644 --- a/mix.exs +++ b/mix.exs @@ -53,7 +53,12 @@ defmodule Athena.MixProject do # Type `mix help deps` for examples and options. defp deps do [ + {:absinthe, "~> 1.7", override: true}, + {:absinthe_graphql_ws, "~> 0.3.3"}, + {:absinthe_plug, "~> 1.5"}, + {:absinthe_relay, "~> 1.5"}, {:credo, "~> 1.4", runtime: false, only: [:dev]}, + {:dataloader, "~> 1.0"}, {:ecto_psql_extras, "~> 0.6"}, {:ecto_sql, "~> 3.1"}, {:eqrcode, "~> 0.1"}, diff --git a/mix.lock b/mix.lock index dd820488..42e49919 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,9 @@ %{ + "absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"}, + "absinthe_graphql_ws": {:hex, :absinthe_graphql_ws, "0.3.3", "74ed188ddcc9edc8bbdd082a02bd3e6c42633cbbb1c51a636f2043fe55f12da7", [:mix], [{:absinthe, "~> 1.6", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_phoenix, "> 0.0.0", [hex: :absinthe_phoenix, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d71461949568c36dd839720b7abe545a78f995407b6f47e5b3f40da655b2015d"}, + "absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.2", "e607b438db900049b9b3760f8ecd0591017a46122fffed7057bf6989020992b5", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "d36918925c380dc7d2ed7d039c9a3b4182ec36723f7417a68745ade5aab22f8d"}, + "absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"}, + "absinthe_relay": {:hex, :absinthe_relay, "1.5.1", "adf298e77cf83d52bae1d7dc1579146bf9b893fcaa7b556d62e81a8c6f997514", [:mix], [{:absinthe, "~> 1.5.0 or ~> 1.6.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "1fd2a3559f8472c5bac5778c8b87ae5a5d7f89b594eba26b684ce1d0345a910a"}, "basic_auth": {:hex, :basic_auth, "2.2.5", "ec2c934e4943b63cfc7d6b01c6f3fa51ade2a518ca36c9c0caee18a90bf98c4e", [:mix], [{:plug, "~> 0.14 or ~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "8b5f067bcfe48d7dc02d43c18ad9e9b54e630c2da720667ac8ed46979b54b7cb"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, @@ -8,6 +13,7 @@ "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"}, + "dataloader": {:hex, :dataloader, "1.0.10", "a42f07641b1a0572e0b21a2a5ae1be11da486a6790f3d0d14512d96ff3e3bbe9", [:mix], [{:ecto, ">= 3.4.3 and < 4.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "54cd70cec09addf4b2ace14cc186a283a149fd4d3ec5475b155951bf33cd963f"}, "db_connection": {:hex, :db_connection, "2.4.1", "6411f6e23f1a8b68a82fa3a36366d4881f21f47fc79a9efb8c615e62050219da", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ea36d226ec5999781a9a8ad64e5d8c4454ecedc7a4d643e4832bf08efca01f00"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, diff --git a/test/athena_web/schema/queries/event_test.exs b/test/athena_web/schema/queries/event_test.exs new file mode 100644 index 00000000..add32336 --- /dev/null +++ b/test/athena_web/schema/queries/event_test.exs @@ -0,0 +1,26 @@ +defmodule AthenaWeb.Schema.Query.EventTest do + @moduledoc false + + use Athena.DataCase + use AthenaWeb.GraphQLCase + + import Athena.Fixture + + @query """ + query Event($id: ID!) { + event(id: $id) { + id + } + } + """ + + test "gets event by id" do + event = event() + + node_id = global_id!(:event, event.id) + + assert result = run!(@query, variables: %{"id" => event.id}) + + assert %{data: %{"event" => %{"id" => ^node_id}}} = result + end +end diff --git a/test/athena_web/schema/queries/node/event_test.exs b/test/athena_web/schema/queries/node/event_test.exs new file mode 100644 index 00000000..1c3e77d8 --- /dev/null +++ b/test/athena_web/schema/queries/node/event_test.exs @@ -0,0 +1,40 @@ +defmodule AthenaWeb.Schema.Query.Node.EventTest do + @moduledoc false + + use Athena.DataCase + use AthenaWeb.GraphQLCase + + import Athena.Fixture + + @query """ + query Node($id: ID!) { + node(id: $id) { + id + ... on Event { + name + insertedAt + updatedAt + } + } + } + """ + + test "gets event by id" do + event = event(name: "Awesome Gathering") + + node_id = global_id!(:event, event.id) + + assert result = run!(@query, variables: %{"id" => node_id}) + + assert %{ + data: %{ + "node" => %{ + "id" => ^node_id, + "name" => "Awesome Gathering", + "insertedAt" => _inserted_at, + "updatedAt" => _updated_at + } + } + } = result + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 0bebe36e..e756a633 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -25,6 +25,7 @@ defmodule Athena.DataCase do import Ecto import Ecto.Changeset import Ecto.Query + import Athena.DataCase end end diff --git a/test/support/graphql_case.ex b/test/support/graphql_case.ex new file mode 100644 index 00000000..6733863f --- /dev/null +++ b/test/support/graphql_case.ex @@ -0,0 +1,146 @@ +defmodule AthenaWeb.GraphQLCase do + @moduledoc """ + This module defines the test case to be used by + tests that require GraphQL. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + # use Absinthe.Phoenix.SubscriptionTest, schema: AthenaWeb.Schema + + # import Phoenix.ChannelTest + + alias AthenaWeb.Endpoint + alias AthenaWeb.Schema + + # @endpoint Endpoint + + using do + quote do + import unquote(__MODULE__) + + use Absinthe.Phoenix.SubscriptionTest, schema: AthenaWeb.Schema + end + end + + @spec run( + input :: String.t() | Absinthe.Language.Source.t() | Absinthe.Language.Document.t(), + options :: Absinthe.run_opts() + ) :: Absinthe.run_result() + def run(input, options \\ []) do + options = options |> Keyword.put_new(:context, %{}) |> put_in([:context, :pubsub], Endpoint) + Absinthe.run(input, Schema, options) + end + + @spec run!( + input :: String.t() | Absinthe.Language.Source.t() | Absinthe.Language.Document.t(), + options :: Absinthe.run_opts() + ) :: Absinthe.result_t() + def run!(input, options \\ []) do + options = options |> Keyword.put_new(:context, %{}) |> put_in([:context, :pubsub], Endpoint) + Absinthe.run!(input, Schema, options) + end + + defmacro assert_no_error(result) do + quote location: :keep do + refute Map.has_key?(unquote(result), :errors) + end + end + + @spec global_id(node_type :: String.t(), source_id :: String.t()) :: String.t() | nil + def global_id(node_type, source_id), + do: Absinthe.Relay.Node.to_global_id(node_type, source_id, Schema) + + @spec global_id!(node_type :: String.t(), source_id :: String.t()) :: String.t() + def global_id!(node_type, source_id), + do: global_id(node_type, source_id) || raise("Invalid ID") + + @spec from_global_id(global_id :: String.t()) :: %{type: atom, id: String.t()} + def from_global_id(global_id) do + {:ok, id} = Absinthe.Relay.Node.from_global_id(global_id, Schema) + + id + end + + @spec add_upload( + opts :: Absinthe.run_opts(), + variable_path :: [String.t()], + file :: Path.t(), + mime :: String.t(), + filename :: String.t() + ) :: Absinthe.run_opts() + def add_upload(opts, variable_path, file, mime \\ "image/jpeg", filename \\ "test.jpg") do + upload = %Plug.Upload{ + content_type: mime, + filename: filename, + path: file + } + + identifier = inspect(make_ref()) + + opts + |> add_variable(variable_path, identifier) + |> add_upload_to_context(identifier, upload) + end + + @spec add_uploads( + opts :: Absinthe.run_opts(), + variable_path :: [String.t()], + files :: [Path.t()] + ) :: Absinthe.run_opts() + def add_uploads(opts, variable_path, files) do + identifier = inspect(make_ref()) + + opts = + add_variable(opts, variable_path, Enum.map(1..Enum.count(files), &"#{identifier}.#{&1}")) + + files + |> Enum.zip(1..Enum.count(files)) + |> Enum.reduce(opts, fn {file, index}, acc -> + add_upload_to_context(acc, "#{identifier}.#{index}", %Plug.Upload{ + content_type: "image/jpeg", + filename: "test.jpg", + path: file + }) + end) + end + + @spec add_variable( + opts :: Absinthe.run_opts(), + variable_path :: [String.t()], + value :: term + ) :: Absinthe.run_opts() + def add_variable(opts, path, value) do + path = + for entry <- path, into: [] do + case entry do + entry when is_binary(entry) -> Access.key(entry, %{}) + end + end + + put_in(opts, [:variables | path], value) + end + + defp add_upload_to_context(opts, name, upload) do + Keyword.update( + opts, + :context, + %{__absinthe_plug__: %{uploads: %{name => upload}}}, + fn context -> + Map.update(context, :__absinthe_plug__, %{uploads: %{name => upload}}, fn plug -> + Map.update( + plug, + :uploads, + %{name => upload}, + &Map.put(&1, name, upload) + ) + end) + end + ) + end +end