├── Procfile ├── .formatter.exs ├── test ├── test_helper.exs ├── scoreboard_web │ ├── views │ │ └── error_view_test.exs │ ├── integration_test.exs │ └── schema_test.exs ├── support │ ├── channel_case.ex │ ├── conn_case.ex │ └── data_case.ex └── scoreboard │ └── games │ └── games_test.exs ├── elixir_buildpack.config ├── lib ├── scoreboard_web │ ├── resolvers │ │ └── games.ex │ ├── router.ex │ ├── views │ │ ├── error_view.ex │ │ └── error_helpers.ex │ ├── gettext.ex │ ├── channels │ │ └── user_socket.ex │ ├── endpoint.ex │ └── schema.ex ├── scoreboard.ex ├── scoreboard │ ├── repo.ex │ ├── games │ │ ├── game.ex │ │ ├── player.ex │ │ ├── score.ex │ │ └── games.ex │ └── application.ex └── scoreboard_web.ex ├── priv ├── repo │ ├── migrations │ │ ├── 20180609141141_create_games.exs │ │ ├── 20180609140734_create_players.exs │ │ └── 20180609141541_create_scores.exs │ └── seeds.exs └── gettext │ ├── en │ └── LC_MESSAGES │ │ └── errors.po │ └── errors.pot ├── .gitignore ├── config ├── test.exs ├── prod.exs ├── config.exs └── dev.exs ├── .github └── workflows │ └── tests.yml ├── mix.exs ├── .iex.exs ├── mix.lock └── README.md /Procfile: -------------------------------------------------------------------------------- 1 | web: MIX_ENV=prod mix phx.server 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | Ecto.Adapters.SQL.Sandbox.mode(Scoreboard.Repo, :manual) 4 | -------------------------------------------------------------------------------- /elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | # Erlang version 2 | erlang_version=21.1 3 | 4 | # Elixir version 5 | elixir_version=1.8 6 | -------------------------------------------------------------------------------- /lib/scoreboard_web/resolvers/games.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoreboardWeb.Resolvers.Games do 2 | def submit_score(%{name: _, total: _, game_id: _} = args, _res), do: Scoreboard.Games.create_score_and_player(args) 3 | def submit_score(args, _res), do: Scoreboard.Games.create_score(args) 4 | end 5 | -------------------------------------------------------------------------------- /lib/scoreboard.ex: -------------------------------------------------------------------------------- 1 | defmodule Scoreboard do 2 | @moduledoc """ 3 | Scoreboard keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180609141141_create_games.exs: -------------------------------------------------------------------------------- 1 | defmodule Scoreboard.Repo.Migrations.CreateGames do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:games, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :name, :string 8 | 9 | timestamps() 10 | end 11 | 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180609140734_create_players.exs: -------------------------------------------------------------------------------- 1 | defmodule Scoreboard.Repo.Migrations.CreatePlayers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:players, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :name, :string 8 | 9 | timestamps() 10 | end 11 | 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/scoreboard/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Scoreboard.Repo do 2 | use Ecto.Repo, 3 | otp_app: :scoreboard, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | @doc """ 7 | Dynamically loads the repository url from the 8 | DATABASE_URL environment variable. 9 | """ 10 | def init(_, opts) do 11 | {:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/scoreboard_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoreboardWeb.Router do 2 | use ScoreboardWeb, :router 3 | 4 | pipeline :api do 5 | plug(:accepts, ["json"]) 6 | end 7 | 8 | scope "/" do 9 | pipe_through(:api) 10 | 11 | forward("/api", Absinthe.Plug, schema: ScoreboardWeb.Schema) 12 | 13 | forward( 14 | "/", 15 | Absinthe.Plug.GraphiQL, 16 | schema: ScoreboardWeb.Schema, 17 | interface: :simple 18 | ) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | /cover 7 | .DS_Store 8 | 9 | # Generated on crash by the VM 10 | erl_crash.dump 11 | 12 | # Files matching config/*.secret.exs pattern contain sensitive 13 | # data and you should not commit them into version control. 14 | # 15 | # Alternatively, you may comment the line below and commit the 16 | # secrets files as long as you replace their contents by environment 17 | # variables. 18 | /config/*.secret.exs 19 | -------------------------------------------------------------------------------- /lib/scoreboard/games/game.ex: -------------------------------------------------------------------------------- 1 | defmodule Scoreboard.Games.Game do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | alias Scoreboard.Games.Score 5 | 6 | @primary_key {:id, :binary_id, autogenerate: true} 7 | @foreign_key_type :binary_id 8 | schema "games" do 9 | field(:name, :string) 10 | has_many(:scores, Score) 11 | 12 | timestamps() 13 | end 14 | 15 | @doc false 16 | def changeset(game, attrs) do 17 | game 18 | |> cast(attrs, [:name]) 19 | |> validate_required([:name]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/scoreboard/games/player.ex: -------------------------------------------------------------------------------- 1 | defmodule Scoreboard.Games.Player do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | alias Scoreboard.Games.Score 5 | 6 | @primary_key {:id, :binary_id, autogenerate: true} 7 | @foreign_key_type :binary_id 8 | schema "players" do 9 | field(:name, :string) 10 | has_many(:scores, Score) 11 | 12 | timestamps() 13 | end 14 | 15 | @doc false 16 | def changeset(player, attrs) do 17 | player 18 | |> cast(attrs, [:name]) 19 | |> validate_required([:name]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :scoreboard, ScoreboardWeb.Endpoint, 6 | http: [port: 4001], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | 12 | # Configure your database 13 | config :scoreboard, Scoreboard.Repo, 14 | username: "postgres", 15 | password: "postgres", 16 | database: "scoreboard_test", 17 | hostname: "localhost", 18 | pool: Ecto.Adapters.SQL.Sandbox 19 | -------------------------------------------------------------------------------- /test/scoreboard_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoreboardWeb.ErrorViewTest do 2 | use ScoreboardWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.json" do 8 | assert render(ScoreboardWeb.ErrorView, "404.json", []) == %{errors: %{detail: "Not Found"}} 9 | end 10 | 11 | test "renders 500.json" do 12 | assert render(ScoreboardWeb.ErrorView, "500.json", []) == 13 | %{errors: %{detail: "Internal Server Error"}} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180609141541_create_scores.exs: -------------------------------------------------------------------------------- 1 | defmodule Scoreboard.Repo.Migrations.CreateScores do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:scores, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :total, :integer 8 | add :player_id, references(:players, on_delete: :nothing, type: :binary_id) 9 | add :game_id, references(:games, on_delete: :nothing, type: :binary_id) 10 | 11 | timestamps() 12 | end 13 | 14 | create index(:scores, [:player_id]) 15 | create index(:scores, [:game_id]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/scoreboard/games/score.ex: -------------------------------------------------------------------------------- 1 | defmodule Scoreboard.Games.Score do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | alias Scoreboard.Games.{Game, Player} 5 | 6 | @primary_key {:id, :binary_id, autogenerate: true} 7 | @foreign_key_type :binary_id 8 | schema "scores" do 9 | field(:total, :integer) 10 | belongs_to(:game, Game) 11 | belongs_to(:player, Player) 12 | 13 | timestamps() 14 | end 15 | 16 | @doc false 17 | def changeset(score, attrs) do 18 | score 19 | |> cast(attrs, [:total, :player_id, :game_id]) 20 | |> validate_required([:total, :player_id, :game_id]) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/scoreboard_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoreboardWeb.ErrorView do 2 | use ScoreboardWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.json", _assigns) do 7 | # %{errors: %{detail: "Internal Server Error"}} 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.json" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | services: 9 | db: 10 | image: postgres:11 11 | ports: ['5432:5432'] 12 | options: >- 13 | --health-cmd pg_isready 14 | --health-interval 10s 15 | --health-timeout 5s 16 | --health-retries 5 17 | steps: 18 | - uses: actions/checkout@v1 19 | - uses: actions/setup-elixir@v1.0.0 20 | with: 21 | otp-version: 22.x 22 | elixir-version: 1.8.x 23 | - name: Install Dependencies 24 | run: | 25 | mix local.rebar --force 26 | mix local.hex --force 27 | mix deps.get 28 | MIX_ENV=test mix compile 29 | - name: Run Tests 30 | run: mix test --cover 31 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :scoreboard, ScoreboardWeb.Endpoint, 4 | load_from_system_env: true, 5 | url: [scheme: "https", host: "damp-beach-31852.herokuapp.com", port: 443], 6 | force_ssl: [rewrite_on: [:x_forwarded_proto]], 7 | cache_static_manifest: "priv/static/cache_manifest.json", 8 | secret_key_base: Map.fetch!(System.get_env(), "SECRET_KEY_BASE") 9 | 10 | config :scoreboard, Scoreboard.Repo, 11 | url: System.get_env("DATABASE_URL"), 12 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 13 | ssl: true 14 | 15 | # Do not print debug messages in production 16 | config :logger, level: :info 17 | 18 | config :cors_plug, 19 | origin: ["https://aaronvotre.com/", "https://sylverstudios.dev/"], 20 | max_age: 86400, 21 | methods: ["GET", "POST"] 22 | -------------------------------------------------------------------------------- /lib/scoreboard_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoreboardWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import ScoreboardWeb.Gettext 9 | 10 | # Simple translation 11 | gettext "Here is the string to translate" 12 | 13 | # Plural translation 14 | ngettext "Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3 17 | 18 | # Domain-based translation 19 | dgettext "errors", "Here is the error message to translate" 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :scoreboard 24 | end 25 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | use Mix.Config 7 | 8 | # General application configuration 9 | config :scoreboard, 10 | ecto_repos: [Scoreboard.Repo], 11 | generators: [binary_id: true] 12 | 13 | # Configures the endpoint 14 | config :scoreboard, ScoreboardWeb.Endpoint, 15 | url: [host: "localhost"], 16 | secret_key_base: "mtuukJQQix8brizUZDXTpcCWA83+ST/UUnJykfSW0yuujpgfC61ivfam1IhyBxDf", 17 | render_errors: [view: ScoreboardWeb.ErrorView, accepts: ~w(json)], 18 | pubsub: [name: Scoreboard.PubSub, adapter: Phoenix.PubSub.PG2] 19 | 20 | # Configures Elixir's Logger 21 | config :logger, :console, 22 | format: "$time $metadata[$level] $message\n", 23 | metadata: [:user_id] 24 | 25 | # Import environment specific config. This must remain at the bottom 26 | # of this file so it overrides the configuration defined above. 27 | import_config "#{Mix.env()}.exs" 28 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Scoreboard.Repo.insert!(%Scoreboard.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | 13 | alias Scoreboard.Repo 14 | alias Scoreboard.Games.{Game, Player, Score} 15 | 16 | aaron = Repo.insert!(%Player{name: "Aaron"}) 17 | sam = Repo.insert!(%Player{name: "Sam"}) 18 | rj = Repo.insert!(%Player{name: "rj"}) 19 | 20 | sv_2 = Repo.insert!(%Game{name: "SuperVirus2"}) 21 | tmm = Repo.insert!(%Game{name: "Trump Money Maker"}) 22 | 23 | players = [aaron, sam, rj] 24 | 25 | scores = 26 | for _ <- 1..30 do 27 | data = %{ 28 | game_id: sv_2.id, 29 | player_id: Enum.random(players).id, 30 | total: :rand.uniform(100), 31 | inserted_at: DateTime.utc_now(), 32 | updated_at: DateTime.utc_now() 33 | } 34 | end 35 | 36 | Repo.insert_all(Score, scores) 37 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoreboardWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common datastructures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | # The default endpoint for testing 24 | @endpoint ScoreboardWeb.Endpoint 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Scoreboard.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(Scoreboard.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/scoreboard/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Scoreboard.Application do 2 | use Application 3 | 4 | # See https://hexdocs.pm/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec 8 | 9 | # Define workers and child supervisors to be supervised 10 | children = [ 11 | # Start the Ecto repository 12 | supervisor(Scoreboard.Repo, []), 13 | # Start the endpoint when the application starts 14 | supervisor(ScoreboardWeb.Endpoint, []) 15 | # Start your own worker by calling: Scoreboard.Worker.start_link(arg1, arg2, arg3) 16 | # worker(Scoreboard.Worker, [arg1, arg2, arg3]), 17 | ] 18 | 19 | # See https://hexdocs.pm/elixir/Supervisor.html 20 | # for other strategies and supported options 21 | opts = [strategy: :one_for_one, name: Scoreboard.Supervisor] 22 | Supervisor.start_link(children, opts) 23 | end 24 | 25 | # Tell Phoenix to update the endpoint configuration 26 | # whenever the application is updated. 27 | def config_change(changed, _new, removed) do 28 | ScoreboardWeb.Endpoint.config_change(changed, removed) 29 | :ok 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoreboardWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common datastructures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | use Phoenix.ConnTest 22 | import ScoreboardWeb.Router.Helpers 23 | 24 | # The default endpoint for testing 25 | @endpoint ScoreboardWeb.Endpoint 26 | end 27 | end 28 | 29 | setup tags do 30 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Scoreboard.Repo) 31 | 32 | unless tags[:async] do 33 | Ecto.Adapters.SQL.Sandbox.mode(Scoreboard.Repo, {:shared, self()}) 34 | end 35 | 36 | {:ok, conn: Phoenix.ConnTest.build_conn()} 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/scoreboard_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoreboardWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", ScoreboardWeb.RoomChannel 6 | 7 | ## Transports 8 | transport(:websocket, Phoenix.Transports.WebSocket, timeout: 45_000) 9 | # transport :longpoll, Phoenix.Transports.LongPoll 10 | 11 | # Socket params are passed from the client and can 12 | # be used to verify and authenticate a user. After 13 | # verification, you can put default assigns into 14 | # the socket that will be set for all channels, ie 15 | # 16 | # {:ok, assign(socket, :user_id, verified_user_id)} 17 | # 18 | # To deny connection, return `:error`. 19 | # 20 | # See `Phoenix.Token` documentation for examples in 21 | # performing token verification on connect. 22 | def connect(_params, socket) do 23 | {:ok, socket} 24 | end 25 | 26 | # Socket id's are topics that allow you to identify all sockets for a given user: 27 | # 28 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 29 | # 30 | # Would allow you to broadcast a "disconnect" event and terminate 31 | # all active sockets and channels for a given user: 32 | # 33 | # ScoreboardWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 34 | # 35 | # Returning `nil` makes this socket anonymous. 36 | def id(_socket), do: nil 37 | end 38 | -------------------------------------------------------------------------------- /lib/scoreboard_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoreboardWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | @doc """ 7 | Translates an error message using gettext. 8 | """ 9 | def translate_error({msg, opts}) do 10 | # When using gettext, we typically pass the strings we want 11 | # to translate as a static argument: 12 | # 13 | # # Translate "is invalid" in the "errors" domain 14 | # dgettext "errors", "is invalid" 15 | # 16 | # # Translate the number of files with plural rules 17 | # dngettext "errors", "1 file", "%{count} files", count 18 | # 19 | # Because the error messages we show in our forms and APIs 20 | # are defined inside Ecto, we need to translate them dynamically. 21 | # This requires us to call the Gettext module passing our gettext 22 | # backend as first argument. 23 | # 24 | # Note we use the "errors" domain, which means translations 25 | # should be written to the errors.po file. The :count option is 26 | # set by Ecto and indicates we should also apply plural rules. 27 | if count = opts[:count] do 28 | Gettext.dngettext(ScoreboardWeb.Gettext, "errors", msg, msg, count, opts) 29 | else 30 | Gettext.dgettext(ScoreboardWeb.Gettext, "errors", msg, opts) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Scoreboard.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias Scoreboard.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query 24 | import Scoreboard.DataCase 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Scoreboard.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(Scoreboard.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | 38 | @doc """ 39 | A helper that transform changeset errors to a map of messages. 40 | 41 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 42 | assert "password is too short" in errors_on(changeset).password 43 | assert %{password: ["password is too short"]} = errors_on(changeset) 44 | 45 | """ 46 | def errors_on(changeset) do 47 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 48 | Enum.reduce(opts, message, fn {key, value}, acc -> 49 | String.replace(acc, "%{#{key}}", to_string(value)) 50 | end) 51 | end) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with brunch.io to recompile .js and .css sources. 9 | config :scoreboard, ScoreboardWeb.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [] 15 | 16 | # ## SSL Support 17 | # 18 | # In order to use HTTPS in development, a self-signed 19 | # certificate can be generated by running the following 20 | # command from your terminal: 21 | # 22 | # openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=www.example.com" -keyout priv/server.key -out priv/server.pem 23 | # 24 | # The `http:` config above can be replaced with: 25 | # 26 | # https: [port: 4000, keyfile: "priv/server.key", certfile: "priv/server.pem"], 27 | # 28 | # If desired, both `http:` and `https:` keys can be 29 | # configured to run both http and https servers on 30 | # different ports. 31 | 32 | # Do not include metadata nor timestamps in development logs 33 | config :logger, :console, format: "[$level] $message\n" 34 | 35 | # Set a higher stacktrace during development. Avoid configuring such 36 | # in production as building large stacktraces may be expensive. 37 | config :phoenix, :stacktrace_depth, 20 38 | 39 | # Configure your database 40 | config :scoreboard, Scoreboard.Repo, 41 | username: "postgres", 42 | password: "postgres", 43 | database: "scoreboard_dev", 44 | hostname: "localhost", 45 | pool_size: 10 46 | -------------------------------------------------------------------------------- /lib/scoreboard_web.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoreboardWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use ScoreboardWeb, :controller 9 | use ScoreboardWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: ScoreboardWeb 23 | import Plug.Conn 24 | import ScoreboardWeb.Router.Helpers 25 | import ScoreboardWeb.Gettext 26 | end 27 | end 28 | 29 | def view do 30 | quote do 31 | use Phoenix.View, 32 | root: "lib/scoreboard_web/templates", 33 | namespace: ScoreboardWeb 34 | 35 | # Import convenience functions from controllers 36 | import Phoenix.Controller, only: [get_flash: 2, view_module: 1] 37 | 38 | import ScoreboardWeb.Router.Helpers 39 | import ScoreboardWeb.ErrorHelpers 40 | import ScoreboardWeb.Gettext 41 | end 42 | end 43 | 44 | def router do 45 | quote do 46 | use Phoenix.Router 47 | import Plug.Conn 48 | import Phoenix.Controller 49 | end 50 | end 51 | 52 | def channel do 53 | quote do 54 | use Phoenix.Channel 55 | import ScoreboardWeb.Gettext 56 | end 57 | end 58 | 59 | @doc """ 60 | When used, dispatch to the appropriate controller/view/etc. 61 | """ 62 | defmacro __using__(which) when is_atom(which) do 63 | apply(__MODULE__, which, []) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/scoreboard_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoreboardWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :scoreboard 3 | 4 | socket("/socket", ScoreboardWeb.UserSocket) 5 | 6 | # Serve at "/" the static files from "priv/static" directory. 7 | # 8 | # You should set gzip to true if you are running phoenix.digest 9 | # when deploying your static files in production. 10 | plug( 11 | Plug.Static, 12 | at: "/", 13 | from: :scoreboard, 14 | gzip: false, 15 | only: ~w(css fonts images js favicon.ico robots.txt) 16 | ) 17 | 18 | # Code reloading can be explicitly enabled under the 19 | # :code_reloader configuration of your endpoint. 20 | if code_reloading? do 21 | plug(Phoenix.CodeReloader) 22 | end 23 | 24 | plug(Plug.Logger) 25 | 26 | plug( 27 | Plug.Parsers, 28 | parsers: [:urlencoded, :multipart, :json], 29 | pass: ["*/*"], 30 | json_decoder: Poison 31 | ) 32 | 33 | plug(Plug.MethodOverride) 34 | plug(Plug.Head) 35 | 36 | # Origins in config 37 | plug(CORSPlug) 38 | 39 | # The session will be stored in the cookie and signed, 40 | # this means its contents can be read but not tampered with. 41 | # Set :encryption_salt if you would also like to encrypt it. 42 | plug( 43 | Plug.Session, 44 | store: :cookie, 45 | key: "_scoreboard_key", 46 | signing_salt: "7dNzVskd" 47 | ) 48 | 49 | plug(ScoreboardWeb.Router) 50 | 51 | @doc """ 52 | Callback invoked for dynamically configuring the endpoint. 53 | 54 | It receives the endpoint configuration and checks if 55 | configuration should be loaded from the system environment. 56 | """ 57 | def init(_key, config) do 58 | if config[:load_from_system_env] do 59 | port = System.get_env("PORT") || raise "expected the PORT environment variable to be set" 60 | {:ok, Keyword.put(config, :http, [:inet6, port: port])} 61 | else 62 | {:ok, config} 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/scoreboard_web/integration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoreboardWeb.IntegrationTest do 2 | use ScoreboardWeb.ConnCase 3 | 4 | alias Scoreboard.Games.{Game, Player, Score} 5 | alias Scoreboard.Repo 6 | 7 | @query """ 8 | query HighScores($game_id: ID!) { 9 | game(id: $game_id) { 10 | id 11 | name 12 | scores { 13 | total 14 | player { 15 | name 16 | } 17 | } 18 | } 19 | } 20 | """ 21 | 22 | describe("Full query") do 23 | test("routing & vars", %{conn: conn}) do 24 | game = Repo.insert!(%Game{name: "SuperVirus"}) 25 | player = Repo.insert!(%Player{name: "RJ"}) 26 | Repo.insert!(%Score{game_id: game.id, player_id: player.id, total: 101}) 27 | 28 | params = [query: @query, variables: %{"game_id" => game.id}] 29 | 30 | response = 31 | conn 32 | |> Phoenix.ConnTest.post("/api", params) 33 | |> Phoenix.ConnTest.json_response(200) 34 | 35 | assert get_in(response, ["data", "game", "scores"]) |> is_list() 36 | end 37 | end 38 | 39 | @query """ 40 | mutation NewScore($game_id: ID!, $name: String!, $total: Int!) { 41 | submit(game_id: $game_id, name: $name, total: $total) { 42 | game { 43 | name 44 | } 45 | player { 46 | name 47 | } 48 | total 49 | } 50 | } 51 | """ 52 | 53 | describe("Mutations") do 54 | test("submit with a new player", %{conn: conn}) do 55 | game = Repo.insert!(%Game{name: "SuperVirus"}) 56 | params = [query: @query, variables: %{"game_id" => game.id, "name" => "RJ", "total" => 101}] 57 | 58 | response = 59 | conn 60 | |> Phoenix.ConnTest.post("/api", params) 61 | |> Phoenix.ConnTest.json_response(200) 62 | 63 | assert "SuperVirus" == get_in(response, ["data", "submit", "game", "name"]) 64 | assert "RJ" == get_in(response, ["data", "submit", "player", "name"]) 65 | assert 101 == get_in(response, ["data", "submit", "total"]) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Scoreboard.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :scoreboard, 7 | version: "0.0.1", 8 | elixir: "~> 1.6", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:phoenix, :gettext] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {Scoreboard.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:absinthe, "~> 1.4"}, 37 | {:absinthe_plug, "~> 1.4"}, 38 | {:cors_plug, "~> 2.0"}, 39 | {:dataloader, "~> 1.0.0"}, 40 | {:ecto, "~> 3.0", override: true}, 41 | {:ecto_sql, "~> 3.0"}, 42 | {:gettext, "~> 0.11"}, 43 | {:phoenix, "~> 1.4.0"}, 44 | {:phoenix_ecto, "~> 4.0"}, 45 | {:phoenix_pubsub, "~> 1.0"}, 46 | {:plug, "~> 1.7"}, 47 | {:plug_cowboy, "~> 2.0"}, 48 | {:poison, "~> 3.1"}, 49 | {:postgrex, "~> 0.14.1"} 50 | ] 51 | end 52 | 53 | # Aliases are shortcuts or tasks specific to the current project. 54 | # For example, to create, migrate and run the seeds file at once: 55 | # 56 | # $ mix ecto.setup 57 | # 58 | # See the documentation for `Mix` for more info on aliases. 59 | defp aliases do 60 | [ 61 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 62 | "ecto.reset": ["ecto.drop", "ecto.setup"], 63 | test: ["ecto.create --quiet", "ecto.migrate", "test"] 64 | ] 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/scoreboard_web/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule ScoreboardWeb.Schema do 2 | use Absinthe.Schema 3 | import Absinthe.Resolution.Helpers, only: [dataloader: 1] 4 | alias Scoreboard.Games 5 | alias ScoreboardWeb.Resolvers 6 | 7 | def context(ctx) do 8 | loader = Dataloader.new() |> Dataloader.add_source(:games, Games.data()) 9 | 10 | Map.put(ctx, :loader, loader) 11 | end 12 | 13 | def plugins do 14 | [Absinthe.Middleware.Dataloader | Absinthe.Plugin.defaults()] 15 | end 16 | 17 | @desc "A Game" 18 | object :game do 19 | field(:id, non_null(:id)) 20 | field(:name, non_null(:string)) 21 | 22 | field :scores, list_of(:score) do 23 | arg(:limit, :integer) 24 | arg(:player_id, :id) 25 | resolve(dataloader(:games)) 26 | end 27 | end 28 | 29 | @desc "A Player" 30 | object :player do 31 | field(:id, non_null(:id)) 32 | field(:name, non_null(:string)) 33 | field(:scores, list_of(:score), resolve: dataloader(:games)) 34 | end 35 | 36 | @desc "A Score." 37 | object :score do 38 | field(:id, non_null(:id)) 39 | field(:total, non_null(:integer)) 40 | field(:player, :player, resolve: dataloader(:games)) 41 | field(:game, :game, resolve: dataloader(:games)) 42 | end 43 | 44 | query do 45 | field :game, :game do 46 | arg(:id, non_null(:id)) 47 | 48 | resolve(fn %{id: game_id}, _ -> 49 | Games.get_game(game_id) 50 | end) 51 | end 52 | 53 | field :player, :player do 54 | arg(:id, non_null(:id)) 55 | 56 | resolve(fn %{id: player_id}, _ -> 57 | Games.get_player(player_id) 58 | end) 59 | end 60 | 61 | field :games, list_of(:game) do 62 | resolve(fn _, _ -> {:ok, Games.get_games()} end) 63 | end 64 | end 65 | 66 | mutation do 67 | @desc "Submit a score" 68 | field :submit_score, type: :score do 69 | arg(:game_id, non_null(:id)) 70 | arg(:player_id, non_null(:id)) 71 | arg(:total, non_null(:integer)) 72 | 73 | resolve(&Resolvers.Games.submit_score/2) 74 | end 75 | 76 | @desc "Submit a score for new player" 77 | field :submit, type: :score do 78 | arg(:game_id, non_null(:id)) 79 | arg(:name, non_null(:string)) 80 | arg(:total, non_null(:integer)) 81 | 82 | resolve(&Resolvers.Games.submit_score/2) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | alias Scoreboard.Games 2 | 3 | defmodule LetMeSee do 4 | @moduledoc """ 5 | Open N+1 in a terminal 6 | Open regular in another terminal 7 | 8 | mix ecto.reset 9 | 10 | iex -S mix 11 | 12 | cmd+r 13 | 14 | LetMeSee.a_game() 15 | 16 | LetMeSee.some_scores() 17 | """ 18 | alias Scoreboard.Repo 19 | alias Scoreboard.Games.{Game, Player} 20 | 21 | @game_id Game |> Repo.get_by!(name: "SuperVirus2") |> Map.get(:id) 22 | @player_id Player |> Repo.get_by!(name: "Aaron") |> Map.get(:id) 23 | 24 | IO.puts( 25 | "\nAaron - This is your self from the past. Remember to reset the DB! mix ecto.reset then do setup\n" 26 | ) 27 | 28 | def a_game() do 29 | request = """ 30 | { 31 | game(id: "#{@game_id}") { 32 | id 33 | name 34 | } 35 | } 36 | """ 37 | 38 | IO.puts("The Request:") 39 | IO.puts(request) 40 | 41 | result = Absinthe.run(request, ScoreboardWeb.Schema) 42 | 43 | IO.puts("\nThe Result:") 44 | result 45 | end 46 | 47 | def some_scores() do 48 | request = """ 49 | { 50 | game(id: "#{@game_id}") { 51 | id 52 | name 53 | scores { 54 | id 55 | total 56 | player { 57 | id 58 | name 59 | } 60 | } 61 | } 62 | } 63 | """ 64 | 65 | IO.puts("The Request:") 66 | IO.puts(request) 67 | 68 | {:ok, result} = Absinthe.run(request, ScoreboardWeb.Schema) 69 | 70 | IO.inspect(result.data, label: "\nResults", limit: 5) 71 | "Total Scores found: #{result |> get_in([:data, "game", "scores"]) |> length()}" 72 | end 73 | 74 | def scores_with_args() do 75 | request = """ 76 | { 77 | game(id: "#{@game_id}") { 78 | id 79 | name 80 | scores(limit: 5, player_id: "#{@player_id}") { 81 | id 82 | total 83 | player { 84 | id 85 | name 86 | } 87 | } 88 | } 89 | } 90 | """ 91 | 92 | IO.puts("The Request:") 93 | IO.puts(request) 94 | 95 | {:ok, result} = Absinthe.run(request, ScoreboardWeb.Schema) 96 | 97 | IO.inspect(result.data, label: "\nResults", limit: 5) 98 | "Aaron's Scores: #{result |> get_in([:data, "game", "scores"]) |> length()}" 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should be %{count} character(s)" 54 | msgid_plural "should be %{count} character(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should have %{count} item(s)" 59 | msgid_plural "should have %{count} item(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be at least %{count} character(s)" 64 | msgid_plural "should be at least %{count} character(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at most %{count} character(s)" 74 | msgid_plural "should be at most %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should have at most %{count} item(s)" 79 | msgid_plural "should have at most %{count} item(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | ## From Ecto.Changeset.validate_number/3 84 | msgid "must be less than %{number}" 85 | msgstr "" 86 | 87 | msgid "must be greater than %{number}" 88 | msgstr "" 89 | 90 | msgid "must be less than or equal to %{number}" 91 | msgstr "" 92 | 93 | msgid "must be greater than or equal to %{number}" 94 | msgstr "" 95 | 96 | msgid "must be equal to %{number}" 97 | msgstr "" 98 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This file is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here as no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_acceptance/3 24 | msgid "must be accepted" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_format/3 28 | msgid "has invalid format" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_subset/3 32 | msgid "has an invalid entry" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_exclusion/3 36 | msgid "is reserved" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.validate_confirmation/3 40 | msgid "does not match confirmation" 41 | msgstr "" 42 | 43 | ## From Ecto.Changeset.no_assoc_constraint/3 44 | msgid "is still associated with this entry" 45 | msgstr "" 46 | 47 | msgid "are still associated with this entry" 48 | msgstr "" 49 | 50 | ## From Ecto.Changeset.validate_length/3 51 | msgid "should be %{count} character(s)" 52 | msgid_plural "should be %{count} character(s)" 53 | msgstr[0] "" 54 | msgstr[1] "" 55 | 56 | msgid "should have %{count} item(s)" 57 | msgid_plural "should have %{count} item(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | 61 | msgid "should be at least %{count} character(s)" 62 | msgid_plural "should be at least %{count} character(s)" 63 | msgstr[0] "" 64 | msgstr[1] "" 65 | 66 | msgid "should have at least %{count} item(s)" 67 | msgid_plural "should have at least %{count} item(s)" 68 | msgstr[0] "" 69 | msgstr[1] "" 70 | 71 | msgid "should be at most %{count} character(s)" 72 | msgid_plural "should be at most %{count} character(s)" 73 | msgstr[0] "" 74 | msgstr[1] "" 75 | 76 | msgid "should have at most %{count} item(s)" 77 | msgid_plural "should have at most %{count} item(s)" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | ## From Ecto.Changeset.validate_number/3 82 | msgid "must be less than %{number}" 83 | msgstr "" 84 | 85 | msgid "must be greater than %{number}" 86 | msgstr "" 87 | 88 | msgid "must be less than or equal to %{number}" 89 | msgstr "" 90 | 91 | msgid "must be greater than or equal to %{number}" 92 | msgstr "" 93 | 94 | msgid "must be equal to %{number}" 95 | msgstr "" 96 | -------------------------------------------------------------------------------- /test/scoreboard_web/schema_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ScoreboardWeb.SchemaTest do 2 | use Scoreboard.DataCase 3 | 4 | alias Scoreboard.Games.{Game, Player, Score} 5 | alias Scoreboard.Repo 6 | 7 | setup do 8 | game = Repo.insert!(%Game{name: "Code Simulator '08"}) 9 | player = Repo.insert!(%Player{name: "Aaron"}) 10 | 11 | %{game: game, player: player} 12 | end 13 | 14 | describe("top level queries") do 15 | test("on data", context) do 16 | document = """ 17 | { 18 | game(id: "#{context.game.id}") { 19 | id 20 | name 21 | } 22 | player(id: "#{context.player.id}") { 23 | id 24 | name 25 | } 26 | } 27 | """ 28 | 29 | result = Absinthe.run(document, ScoreboardWeb.Schema) 30 | 31 | assert {:ok, %{data: data}} = result 32 | assert get_in(data, ["game", "name"]) == context.game.name 33 | assert get_in(data, ["player", "name"]) == context.player.name 34 | end 35 | 36 | test("many games", _context) do 37 | document = """ 38 | { 39 | games { 40 | id 41 | name 42 | } 43 | } 44 | """ 45 | 46 | result = Absinthe.run(document, ScoreboardWeb.Schema) 47 | 48 | assert {:ok, %{data: data}} = result 49 | assert length(data["games"]) < 10 50 | first = hd(data["games"]) 51 | refute is_nil(first["name"]) 52 | refute is_nil(first["id"]) 53 | end 54 | end 55 | 56 | describe("nested queries") do 57 | test("on associations", context) do 58 | score = 59 | Repo.insert!(%Score{game_id: context.game.id, player_id: context.player.id, total: 101}) 60 | 61 | document = """ 62 | { 63 | game(id: "#{context.game.id}") { 64 | id 65 | name 66 | scores { 67 | total 68 | player { 69 | id 70 | name 71 | } 72 | } 73 | } 74 | } 75 | """ 76 | 77 | result = Absinthe.run(document, ScoreboardWeb.Schema) 78 | 79 | assert {:ok, %{data: data}} = result 80 | assert get_in(data, ["game", "name"]) == context.game.name 81 | 82 | first = fn :get, data, next -> data |> hd() |> next.() end 83 | assert get_in(data, ["game", "scores", first, "total"]) == score.total 84 | assert get_in(data, ["game", "scores", first, "player", "name"]) == context.player.name 85 | end 86 | 87 | test("passing limit param to scores query", context) do 88 | Repo.insert!(%Score{game_id: context.game.id, player_id: context.player.id, total: 101}) 89 | 90 | document = """ 91 | { 92 | game(id: "#{context.game.id}") { 93 | id 94 | name 95 | scores(limit: 0) { 96 | total 97 | } 98 | } 99 | } 100 | """ 101 | 102 | result = Absinthe.run(document, ScoreboardWeb.Schema) 103 | 104 | assert {:ok, %{data: data}} = result 105 | assert length(get_in(data, ["game", "scores"])) == 0 106 | end 107 | 108 | test("passing limit & player_id param to scores query", context) do 109 | sam = Repo.insert!(%Player{name: "Sam"}) 110 | Repo.insert!(%Score{game_id: context.game.id, player_id: context.player.id, total: 101}) 111 | Repo.insert!(%Score{game_id: context.game.id, player_id: sam.id, total: 12}) 112 | 113 | document = """ 114 | { 115 | game(id: "#{context.game.id}") { 116 | id 117 | name 118 | scores(limit: 5, player_id: "#{context.player.id}") { 119 | total 120 | player { 121 | id 122 | } 123 | } 124 | } 125 | } 126 | """ 127 | 128 | result = Absinthe.run(document, ScoreboardWeb.Schema) 129 | 130 | assert {:ok, %{data: data}} = result 131 | 132 | for %{"player" => %{"id" => id}} = _score <- get_in(data, ["game", "scores"]) do 133 | assert id == context.player.id 134 | end 135 | end 136 | end 137 | 138 | describe("mutation queries") do 139 | test("create score", %{game: game, player: player}) do 140 | score = 37 141 | 142 | document = """ 143 | mutation { 144 | submitScore(player_id:"#{player.id}", game_id:"#{game.id}", total:#{score}) { 145 | id 146 | total 147 | player { 148 | name 149 | } 150 | game { 151 | name 152 | } 153 | } 154 | } 155 | """ 156 | 157 | result = Absinthe.run(document, ScoreboardWeb.Schema) 158 | 159 | assert {:ok, %{data: data}} = result 160 | assert get_in(data, ["submitScore", "game", "name"]) == game.name 161 | assert get_in(data, ["submitScore", "player", "name"]) == player.name 162 | assert get_in(data, ["submitScore", "total"]) == score 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "absinthe": {:hex, :absinthe, "1.4.13", "81eb2ff41f1b62cd6e992955f62c22c042d1079b7936c27f5f7c2c806b8fc436", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 3 | "absinthe_plug": {:hex, :absinthe_plug, "1.4.5", "f63d52a76c870cd5f11d4bed8f61351ab5c5f572c5eb0479a0137f9f730ba33d", [:mix], [{:absinthe, "~> 1.4.11", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.2 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, 5 | "cors_plug": {:hex, :cors_plug, "2.0.0", "238ddb479f92b38f6dc1ae44b8d81f0387f9519101a6da442d543ab70ee0e482", [:mix], [{:plug, "~> 1.3 or ~> 1.4 or ~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "cowboy": {:hex, :cowboy, "2.6.1", "f2e06f757c337b3b311f9437e6e072b678fcd71545a7b2865bdaa154d078593f", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "cowlib": {:hex, :cowlib, "2.7.0", "3ef16e77562f9855a2605900cedb15c1462d76fb1be6a32fc3ae91973ee543d2", [:rebar3], [], "hexpm"}, 8 | "dataloader": {:hex, :dataloader, "1.0.3", "3943b1b1ebe5ef59e88065cdbef9f1c9a9cb997fb90fed7ff4141b0b015eaa57", [:mix], [{:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, 9 | "db_connection": {:hex, :db_connection, "2.0.5", "ddb2ba6761a08b2bb9ca0e7d260e8f4dd39067426d835c24491a321b7f92a4da", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "decimal": {:hex, :decimal, "1.7.0", "30d6b52c88541f9a66637359ddf85016df9eb266170d53105f02e4a67e00c5aa", [:mix], [], "hexpm"}, 11 | "ecto": {:hex, :ecto, "3.0.7", "44dda84ac6b17bbbdeb8ac5dfef08b7da253b37a453c34ab1a98de7f7e5fec7f", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, 12 | "ecto_sql": {:hex, :ecto_sql, "3.0.5", "7e44172b4f7aca4469f38d7f6a3da394dbf43a1bcf0ca975e958cb957becd74e", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.6", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], [], "hexpm"}, 14 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, 15 | "phoenix": {:hex, :phoenix, "1.4.2", "3a1250f22010daeee265923bae02f10b5434b569b999c1b18100b5da05834d93", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, 16 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 17 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"}, 18 | "plug": {:hex, :plug, "1.7.2", "d7b7db7fbd755e8283b6c0a50be71ec0a3d67d9213d74422d9372effc8e87fd1", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"}, 19 | "plug_cowboy": {:hex, :plug_cowboy, "2.0.1", "d798f8ee5acc86b7d42dbe4450b8b0dadf665ce588236eb0a751a132417a980e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 20 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, 21 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 22 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, 23 | "postgrex": {:hex, :postgrex, "0.14.1", "63247d4a5ad6b9de57a0bac5d807e1c32d41e39c04b8a4156a26c63bcd8a2e49", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, 24 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, 25 | "telemetry": {:hex, :telemetry, "0.3.0", "099a7f3ce31e4780f971b4630a3c22ec66d22208bc090fe33a2a3a6a67754a73", [:rebar3], [], "hexpm"}, 26 | } 27 | -------------------------------------------------------------------------------- /test/scoreboard/games/games_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Scoreboard.GamesTest do 2 | use Scoreboard.DataCase 3 | 4 | alias Scoreboard.Games 5 | 6 | describe "players" do 7 | alias Scoreboard.Games.Player 8 | 9 | @valid_attrs %{name: "some name"} 10 | @update_attrs %{name: "some updated name"} 11 | @invalid_attrs %{name: nil} 12 | 13 | def player_fixture(attrs \\ %{}) do 14 | {:ok, player} = 15 | attrs 16 | |> Enum.into(@valid_attrs) 17 | |> Games.create_player() 18 | 19 | player 20 | end 21 | 22 | test "list_players/0 returns all players" do 23 | player = player_fixture() 24 | assert Games.list_players() == [player] 25 | end 26 | 27 | test "get_player!/1 returns the player with given id" do 28 | player = player_fixture() 29 | assert Games.get_player!(player.id) == player 30 | end 31 | 32 | test "create_player/1 with valid data creates a player" do 33 | assert {:ok, %Player{} = player} = Games.create_player(@valid_attrs) 34 | assert player.name == "some name" 35 | end 36 | 37 | test "create_player/1 with invalid data returns error changeset" do 38 | assert {:error, %Ecto.Changeset{}} = Games.create_player(@invalid_attrs) 39 | end 40 | 41 | test "update_player/2 with valid data updates the player" do 42 | player = player_fixture() 43 | assert {:ok, player} = Games.update_player(player, @update_attrs) 44 | assert %Player{} = player 45 | assert player.name == "some updated name" 46 | end 47 | 48 | test "update_player/2 with invalid data returns error changeset" do 49 | player = player_fixture() 50 | assert {:error, %Ecto.Changeset{}} = Games.update_player(player, @invalid_attrs) 51 | assert player == Games.get_player!(player.id) 52 | end 53 | 54 | test "delete_player/1 deletes the player" do 55 | player = player_fixture() 56 | assert {:ok, %Player{}} = Games.delete_player(player) 57 | assert_raise Ecto.NoResultsError, fn -> Games.get_player!(player.id) end 58 | end 59 | 60 | test "change_player/1 returns a player changeset" do 61 | player = player_fixture() 62 | assert %Ecto.Changeset{} = Games.change_player(player) 63 | end 64 | end 65 | 66 | describe "games" do 67 | alias Scoreboard.Games.Game 68 | 69 | @valid_attrs %{name: "some name"} 70 | @update_attrs %{name: "some updated name"} 71 | @invalid_attrs %{name: nil} 72 | 73 | def game_fixture(attrs \\ %{}) do 74 | {:ok, game} = 75 | attrs 76 | |> Enum.into(@valid_attrs) 77 | |> Games.create_game() 78 | 79 | game 80 | end 81 | 82 | test "list_games/0 returns all games" do 83 | game = game_fixture() 84 | assert Games.list_games() == [game] 85 | end 86 | 87 | test "get_game!/1 returns the game with given id" do 88 | game = game_fixture() 89 | assert Games.get_game!(game.id) == game 90 | end 91 | 92 | test "create_game/1 with valid data creates a game" do 93 | assert {:ok, %Game{} = game} = Games.create_game(@valid_attrs) 94 | assert game.name == "some name" 95 | end 96 | 97 | test "create_game/1 with invalid data returns error changeset" do 98 | assert {:error, %Ecto.Changeset{}} = Games.create_game(@invalid_attrs) 99 | end 100 | 101 | test "update_game/2 with valid data updates the game" do 102 | game = game_fixture() 103 | assert {:ok, game} = Games.update_game(game, @update_attrs) 104 | assert %Game{} = game 105 | assert game.name == "some updated name" 106 | end 107 | 108 | test "update_game/2 with invalid data returns error changeset" do 109 | game = game_fixture() 110 | assert {:error, %Ecto.Changeset{}} = Games.update_game(game, @invalid_attrs) 111 | assert game == Games.get_game!(game.id) 112 | end 113 | 114 | test "delete_game/1 deletes the game" do 115 | game = game_fixture() 116 | assert {:ok, %Game{}} = Games.delete_game(game) 117 | assert_raise Ecto.NoResultsError, fn -> Games.get_game!(game.id) end 118 | end 119 | 120 | test "change_game/1 returns a game changeset" do 121 | game = game_fixture() 122 | assert %Ecto.Changeset{} = Games.change_game(game) 123 | end 124 | end 125 | 126 | describe "scores" do 127 | alias Scoreboard.Games.{Game, Player, Score} 128 | 129 | @valid_attrs %{total: 42} 130 | @update_attrs %{total: 43} 131 | @invalid_attrs %{total: nil} 132 | 133 | def game_and_player() do 134 | game = Repo.insert!(%Game{name: "Code Simulator '08"}) 135 | player = Repo.insert!(%Player{name: "Aaron"}) 136 | %{game: game, player: player} 137 | end 138 | 139 | def score_fixture(attrs \\ %{}) do 140 | %{game: game, player: player} = game_and_player() 141 | 142 | {:ok, score} = 143 | attrs 144 | |> Map.merge(%{game_id: game.id, player_id: player.id}) 145 | |> Enum.into(@valid_attrs) 146 | |> Games.create_score() 147 | 148 | score 149 | end 150 | 151 | test "list_scores/0 returns all scores" do 152 | score = score_fixture() 153 | assert Games.list_scores() == [score] 154 | end 155 | 156 | test "get_score!/1 returns the score with given id" do 157 | score = score_fixture() 158 | assert Games.get_score!(score.id) == score 159 | end 160 | 161 | test "create_score/1 with valid data creates a score" do 162 | %{game: game, player: player} = game_and_player() 163 | attrs = @valid_attrs |> Map.merge(%{game_id: game.id, player_id: player.id}) 164 | 165 | assert {:ok, %Score{} = score} = Games.create_score(attrs) 166 | 167 | assert score.total == 42 168 | end 169 | 170 | test "create_score/1 with invalid data returns error changeset" do 171 | assert {:error, %Ecto.Changeset{}} = Games.create_score(@invalid_attrs) 172 | end 173 | 174 | test "update_score/2 with valid data updates the score" do 175 | score = score_fixture() 176 | assert {:ok, score} = Games.update_score(score, @update_attrs) 177 | assert %Score{} = score 178 | assert score.total == 43 179 | end 180 | 181 | test "update_score/2 with invalid data returns error changeset" do 182 | score = score_fixture() 183 | assert {:error, %Ecto.Changeset{}} = Games.update_score(score, @invalid_attrs) 184 | assert score == Games.get_score!(score.id) 185 | end 186 | 187 | test "delete_score/1 deletes the score" do 188 | score = score_fixture() 189 | assert {:ok, %Score{}} = Games.delete_score(score) 190 | assert_raise Ecto.NoResultsError, fn -> Games.get_score!(score.id) end 191 | end 192 | 193 | test "change_score/1 returns a score changeset" do 194 | score = score_fixture() 195 | assert %Ecto.Changeset{} = Games.change_score(score) 196 | end 197 | end 198 | 199 | describe "multi functions" do 200 | alias Scoreboard.Games.{Game, Player, Score} 201 | 202 | @attrs %{total: 42, game_id: "", name: "Aaron"} 203 | 204 | def game(), do: Repo.insert!(%Game{name: "Code Simulator '08"}) 205 | 206 | test "create_score_and_player/1 returns a score" do 207 | game = game() 208 | args = Map.merge(@attrs, %{game_id: game.id}) 209 | 210 | assert {:ok, score} = Games.create_score_and_player(args) 211 | assert Repo.preload(score, :player).player.name == "Aaron" 212 | end 213 | end 214 | 215 | end 216 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://github.com/shamshirz/scoreboard/workflows/Tests/badge.svg) 2 | 3 | # Scoreboard 4 | 5 | ## Talk links 6 | 7 | * [Live GraphiQL Example](https://damp-beach-31852.herokuapp.com/) 8 | * [2upervirus: Supervirus 2 Beta](https://aaronvotre.com) 9 | * [SylverStudios Games](https://sylverstud.io/s) 10 | * [Conf Bio](https://elixirconf.com/2018/speakers/aaron-votre) 11 | * [Slides](https://docs.google.com/presentation/d/1tvKx9Gj6k5i51-v41fmQ2Go4iZXHDuPAI3eyJpCEX3E/edit?usp=sharing) 12 | 13 | The goal of this repo is to be a robust example of [Absinthe](https://github.com/absinthe-graphql/absinthe) and [Dataloder](https://github.com/absinthe-graphql/dataloader) to supplement a getting started guide. 14 | 15 | Browser games and leaderboards. You play them, you love them, let's make a server to store some of those scores so you can provide persistence for your players. 16 | 17 | # Run it 18 | 19 | To start your Phoenix server: 20 | 21 | * Install dependencies with `mix deps.get` 22 | * Create and migrate your database with `mix ecto.setup` 23 | * Start Phoenix endpoint with `mix phx.server` 24 | * Open graphiql to `http://localhost:4000/` 25 | 26 | 27 | # Write your own 28 | 29 | These are the steps that I went through building this app. They each focus on one chunk of work, but not exactly a single feature. They try to introduce libraries one at a time. 30 | 31 | Table of Contents 32 | 33 | * [Generate an App](#generate-an-app) 34 | * [Ecto Schemas](#ecto-schemas) 35 | * [Absinthe Setup](#absinthe-setup) 36 | * [Dataloader](#dataloader) 37 | * [Mutations](#mutations) 38 | * [Limit & filter Scores](#limit--filter-scores) 39 | * [Phoenix Routing & Graphiql](#phoenix-routing--graphiql) 40 | 41 | ## Generate an App 42 | Make a basic Phoenix app to serve an API only and use UUIDs instead of int Ids. 43 | 44 | ``` 45 | mix phx.new ./scoreboard --no-html --no-brunch --binary-id 46 | ``` 47 | 48 | Very neat, Adds in config: `generators: [binary_id: true]` 49 | 50 | More info. 51 | > `mix help phx.new` 52 | 53 | 54 | 55 | ## Ecto Schemas 56 | 57 | We will auto generate a context to access these Ecto Schemas 58 | ```bash 59 | mix help phx.gen.context 60 | ``` 61 | 62 | `Player` and `Game` are many to many, using the `Score` to map them together. 63 | 64 | ``` 65 | mix phx.gen.context Games Player players name:string 66 | mix phx.gen.context Games Game games name:string 67 | 68 | mix phx.gen.context Games Score scores total:integer player_id:references:players game_id:references:games 69 | ``` 70 | 71 | Let's make sure it works 72 | 73 | ```bash 74 | mix test 75 | ``` 76 | 77 | This is nice, but I want to have the associations available on my Structs. 78 | Updating this is pretty easy, we can just replace the foreign binary_ids with the `[has_*, belongs_*]` macros. 79 | 80 | In `Scoreboard.Games.Score` Replace 81 | 82 | ```elixir 83 | field :player_id, :binary_id 84 | field :game_id, :binary_id 85 | ``` 86 | 87 | With 88 | 89 | ```elixir 90 | belongs_to(:game, Game) 91 | belongs_to(:player, Player) 92 | ``` 93 | 94 | I added the associations to the [Game and Player Schemas](https://github.com/shamshirz/scoreboard/commit/0d403a75d6fdeb06a572c2f2e9a400ac1244db66#diff-1c331c359bcb59c0a55389158b9e40fb) schemas as well. 95 | 96 | ## Absinthe Setup 97 | 98 | [See the diff in this PR](https://github.com/shamshirz/scoreboard/pull/1) 99 | 100 | Your API will revolve around your Absinthe `Schema`. To get this started we will define some types, eerily similary to Ecto. 101 | 102 | The Game Type 103 | ``` 104 | @desc "A Game" 105 | object :game do 106 | field(:id, non_null(:id)) 107 | field(:name, non_null(:string)) 108 | end 109 | ``` 110 | 111 | This will define your API and how your incoming document maps to elixir functions. 112 | 113 | Your Graph doesn't have to be anything like your DB, but in this case, it is. 114 | This is the defintion for the API. Everything that will be exposed and explorable is defined in our `schema.ex`. 115 | 116 | ```elixir 117 | query do 118 | field :game, :game do 119 | arg(:id, non_null(:id)) 120 | 121 | resolve(fn %{id: game_id}, _ -> 122 | Games.get_game(game_id) 123 | end) 124 | end 125 | end 126 | ``` 127 | There are some informative tests [Here](https://github.com/shamshirz/scoreboard/pull/1). 128 | 129 | ## Dataloader 130 | 131 | Dataloader takes care of batching our queries for us. It dramatically reduces code length and complexity too. 132 | 133 | [Dataloader PR](https://github.com/shamshirz/scoreboard/pull/3/files) 134 | 135 | 136 | ## Mutations 137 | 138 | When we change data via Absinthe, these are called Mutations. Much like the "root query", we have a "root mutation". After the mutation, you can explore the graph and resolve the same way we do in queries. 139 | 140 | [Add our first mutation](https://github.com/shamshirz/scoreboard/pull/8) 141 | 142 | ```elixir 143 | mutation do 144 | @desc "Submit a score" 145 | field :submit_score, type: :score do 146 | arg(:game_id, non_null(:id)) 147 | arg(:player_id, non_null(:id)) 148 | arg(:total, non_null(:integer)) 149 | 150 | resolve(&Resolvers.Games.submit_score/2) 151 | end 152 | end 153 | ``` 154 | 155 | 156 | ## Limit & filter Scores 157 | 158 | Allow optional args on the `scores` key of our `game` type. 159 | 160 | ```elixir 161 | field :scores, list_of(:score) do 162 | arg(:limit, :integer) 163 | arg(:player_id, :id) 164 | resolve(dataloader(:games)) 165 | end 166 | ``` 167 | 168 | And update `Scoreboard.Games.query/2` to handle params 169 | 170 | ```elixir 171 | def query(Score, params) do 172 | params 173 | |> Map.to_list() 174 | |> Enum.reduce(Score, &apply_param/2) 175 | end 176 | 177 | def apply_param({:limit, num}, queryable), do: queryable |> limit(^num) 178 | ``` 179 | 180 | [Games.query/2 PR here](https://github.com/shamshirz/scoreboard/pull/9/files) 181 | 182 | 183 | ## Phoenix Routing & Graphiql 184 | 185 | Now that we can provide something useful, let's try and running the server. We just need to add a route that goes to our Absinthe schema. 186 | 187 | [Phoenix Route PR](https://github.com/shamshirz/scoreboard/pull/9) 188 | 189 | `ScoreboardWeb.Router` 190 | ```elixir 191 | forward( 192 | "/", 193 | Absinthe.Plug.GraphiQL, 194 | schema: ScoreboardWeb.Schema, 195 | interface: :simple 196 | ) 197 | 198 | forward("/api", Absinthe.Plug, schema: ScoreboardWeb.Schema) 199 | ``` 200 | 201 | Once the router is updated we can explore our absinthe schema using [Graphiql](https://github.com/graphql/graphiql). It's a UI tool that you can view schemas and write queries with. There are download docs in the repo, but I installed it through `brew`. 202 | 203 | Start the Server 204 | ```bash 205 | mix phx.server 206 | ``` 207 | 208 | [Open Graphiql locally](http://localhost:4000/) 209 | 210 | # Heroku 211 | 212 | ```bash 213 | heroku config:set x="y" # Set env Vars for runtime (not compile-time) 214 | git push heroku master # Deploy 215 | heroku open #Open browser to app graphiql interface! 216 | heroku run "POOL_SIZE=2 mix hello.task" #Run a mix task, & limit db connections 217 | 218 | # Postgres stuff 219 | heroku pg:info # get db_name from add-on field. 220 | heroku pg:reset DB_NAME # Didn't need 221 | heroku run MIX_ENV=prod mix ecto.migrate 222 | heroku run MIX_ENV=prod mix run priv/repo/seeds.exs 223 | ``` 224 | # Learn more 225 | 226 | Code specific resources 227 | 228 | * [Phx generators](https://hexdocs.pm/phoenix/phoenix_mix_tasks.html) 229 | * [Absinthe Docs](https://hexdocs.pm/absinthe/overview.html) 230 | * [phx.new Docs](https://github.com/phoenixframework/phoenix/blob/master/installer/lib/mix/tasks/phx.new.ex) 231 | 232 | Talk resources 233 | 234 | * [Talk guidelines](https://opensource.com/life/14/1/get-your-conference-talk-submission-accepted]) 235 | * [Elixir Conf Proposal Form](https://docs.google.com/forms/d/e/1FAIpQLSf4CiP2UtB7Www47yVv592w_kHK4qBwZZpQcMlaQJDvDU7qpg/viewform]) 236 | * [Chad Fowlwer Quote](https://twitter.com/chadfowler/status/671944358388723712]) 237 | * [Spotify Talk Example](https://vimeo.com/85490944]) 238 | * [Evan on Storytelling](https://www.deconstructconf.com/2017/evan-czaplicki-on-storytelling]) 239 | 240 | 241 | The fun stuff 242 | * [ Absinthe Subscriptions With Annkissam ](https://www.annkissam.com/elixir/alembic/posts/2018/07/13/graphql-subscriptions-connecting-phoenix-applications-with-absinthe-and-websockets.html#an-elixir-graphql-client) 243 | * [ Subscription Guide ](https://hexdocs.pm/absinthe/subscriptions.html) -------------------------------------------------------------------------------- /lib/scoreboard/games/games.ex: -------------------------------------------------------------------------------- 1 | defmodule Scoreboard.Games do 2 | @moduledoc """ 3 | The Games context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | alias Scoreboard.Games.{Player, Score} 8 | alias Scoreboard.Repo 9 | alias Ecto.Multi 10 | 11 | 12 | def data(), do: Dataloader.Ecto.new(Scoreboard.Repo, query: &query/2) 13 | 14 | @doc """ 15 | Pattern match to build more specific queries 16 | """ 17 | def query(Score, params) do 18 | params 19 | |> Map.put_new(:limit, 10) 20 | |> Map.put_new(:order, :total) 21 | |> Map.to_list() 22 | |> Enum.reduce(Score, &apply_param/2) 23 | end 24 | 25 | def query(queryable, _params), do: queryable 26 | 27 | @doc """ 28 | reduce function 29 | """ 30 | def apply_param({:limit, num}, queryable), do: queryable |> limit(^num) 31 | def apply_param({:order, field}, queryable), do: queryable |> order_by(desc: ^field) 32 | 33 | def apply_param({:player_id, player_id}, queryable), 34 | do: queryable |> where(player_id: ^player_id) 35 | 36 | def apply_param(_param, queryable), do: queryable 37 | 38 | def get(queryable, id) do 39 | case Repo.get(queryable, id) do 40 | nil -> 41 | {:error, :not_found} 42 | 43 | result -> 44 | {:ok, result} 45 | end 46 | end 47 | 48 | @doc """ 49 | Returns the list of players. 50 | 51 | ## Examples 52 | 53 | iex> list_players() 54 | [%Player{}, ...] 55 | 56 | """ 57 | def list_players do 58 | Repo.all(Player) 59 | end 60 | 61 | @doc """ 62 | Gets a single player. 63 | 64 | Raises `Ecto.NoResultsError` if the Player does not exist. 65 | 66 | ## Examples 67 | 68 | iex> get_player!(123) 69 | %Player{} 70 | 71 | iex> get_player!(456) 72 | ** (Ecto.NoResultsError) 73 | 74 | """ 75 | def get_player!(id), do: Repo.get!(Player, id) 76 | 77 | @doc """ 78 | Gets a single player. 79 | 80 | Returns an :ok|error tuple 81 | 82 | ## Examples 83 | 84 | iex> get_player!(123) 85 | {:ok, %Player{}} 86 | 87 | iex> get_player!(456) 88 | {:error, reason} 89 | 90 | """ 91 | def get_player(id), do: get(Player, id) 92 | 93 | @doc """ 94 | Creates a player. 95 | 96 | ## Examples 97 | 98 | iex> create_player(%{field: value}) 99 | {:ok, %Player{}} 100 | 101 | iex> create_player(%{field: bad_value}) 102 | {:error, %Ecto.Changeset{}} 103 | 104 | """ 105 | def create_player(attrs \\ %{}) do 106 | %Player{} 107 | |> Player.changeset(attrs) 108 | |> Repo.insert() 109 | end 110 | 111 | @doc """ 112 | Updates a player. 113 | 114 | ## Examples 115 | 116 | iex> update_player(player, %{field: new_value}) 117 | {:ok, %Player{}} 118 | 119 | iex> update_player(player, %{field: bad_value}) 120 | {:error, %Ecto.Changeset{}} 121 | 122 | """ 123 | def update_player(%Player{} = player, attrs) do 124 | player 125 | |> Player.changeset(attrs) 126 | |> Repo.update() 127 | end 128 | 129 | @doc """ 130 | Deletes a Player. 131 | 132 | ## Examples 133 | 134 | iex> delete_player(player) 135 | {:ok, %Player{}} 136 | 137 | iex> delete_player(player) 138 | {:error, %Ecto.Changeset{}} 139 | 140 | """ 141 | def delete_player(%Player{} = player) do 142 | Repo.delete(player) 143 | end 144 | 145 | @doc """ 146 | Returns an `%Ecto.Changeset{}` for tracking player changes. 147 | 148 | ## Examples 149 | 150 | iex> change_player(player) 151 | %Ecto.Changeset{source: %Player{}} 152 | 153 | """ 154 | def change_player(%Player{} = player) do 155 | Player.changeset(player, %{}) 156 | end 157 | 158 | alias Scoreboard.Games.Game 159 | 160 | @doc """ 161 | Returns the list of games. 162 | 163 | ## Examples 164 | 165 | iex> list_games() 166 | [%Game{}, ...] 167 | 168 | """ 169 | def list_games do 170 | Repo.all(Game) 171 | end 172 | 173 | @doc """ 174 | Gets a single game. 175 | 176 | Raises `Ecto.NoResultsError` if the Game does not exist. 177 | 178 | ## Examples 179 | 180 | iex> get_game!(123) 181 | %Game{} 182 | 183 | iex> get_game!(456) 184 | ** (Ecto.NoResultsError) 185 | 186 | """ 187 | def get_game!(id), do: Repo.get!(Game, id) 188 | 189 | @doc """ 190 | Gets a single game. 191 | 192 | Returns a ok|error tuple 193 | 194 | ## Examples 195 | 196 | iex> get_game(123) 197 | {:ok, %Game{}} 198 | 199 | iex> get_game(456) 200 | {:error, :not_found} 201 | 202 | """ 203 | def get_game(id), do: get(Game, id) 204 | 205 | def get_games() do 206 | Game 207 | |> limit(10) 208 | |> Repo.all() 209 | end 210 | 211 | @doc """ 212 | Creates a game. 213 | 214 | ## Examples 215 | 216 | iex> create_game(%{field: value}) 217 | {:ok, %Game{}} 218 | 219 | iex> create_game(%{field: bad_value}) 220 | {:error, %Ecto.Changeset{}} 221 | 222 | """ 223 | def create_game(attrs \\ %{}) do 224 | %Game{} 225 | |> Game.changeset(attrs) 226 | |> Repo.insert() 227 | end 228 | 229 | @doc """ 230 | Updates a game. 231 | 232 | ## Examples 233 | 234 | iex> update_game(game, %{field: new_value}) 235 | {:ok, %Game{}} 236 | 237 | iex> update_game(game, %{field: bad_value}) 238 | {:error, %Ecto.Changeset{}} 239 | 240 | """ 241 | def update_game(%Game{} = game, attrs) do 242 | game 243 | |> Game.changeset(attrs) 244 | |> Repo.update() 245 | end 246 | 247 | @doc """ 248 | Deletes a Game. 249 | 250 | ## Examples 251 | 252 | iex> delete_game(game) 253 | {:ok, %Game{}} 254 | 255 | iex> delete_game(game) 256 | {:error, %Ecto.Changeset{}} 257 | 258 | """ 259 | def delete_game(%Game{} = game) do 260 | Repo.delete(game) 261 | end 262 | 263 | @doc """ 264 | Returns an `%Ecto.Changeset{}` for tracking game changes. 265 | 266 | ## Examples 267 | 268 | iex> change_game(game) 269 | %Ecto.Changeset{source: %Game{}} 270 | 271 | """ 272 | def change_game(%Game{} = game) do 273 | Game.changeset(game, %{}) 274 | end 275 | 276 | alias Scoreboard.Games.Score 277 | 278 | @doc """ 279 | Returns the list of scores. 280 | 281 | ## Examples 282 | 283 | iex> list_scores() 284 | [%Score{}, ...] 285 | 286 | """ 287 | def list_scores do 288 | Repo.all(Score) 289 | end 290 | 291 | @doc """ 292 | Gets a single score. 293 | 294 | Raises `Ecto.NoResultsError` if the Score does not exist. 295 | 296 | ## Examples 297 | 298 | iex> get_score!(123) 299 | %Score{} 300 | 301 | iex> get_score!(456) 302 | ** (Ecto.NoResultsError) 303 | 304 | """ 305 | def get_score!(id), do: Repo.get!(Score, id) 306 | 307 | @doc """ 308 | Creates a score. 309 | 310 | ## Examples 311 | 312 | iex> create_score(%{field: value}) 313 | {:ok, %Score{}} 314 | 315 | iex> create_score(%{field: bad_value}) 316 | {:error, %Ecto.Changeset{}} 317 | 318 | """ 319 | def create_score(attrs \\ %{}) do 320 | %Score{} 321 | |> Score.changeset(attrs) 322 | |> Repo.insert() 323 | end 324 | 325 | @doc """ 326 | Create a score and new user 327 | """ 328 | @spec create_score_and_player(%{name: String.t(), total: integer(), game_id: String.t()}) :: {:ok, %Score{}} | {:error, Ecto.Changeset.t()} 329 | def create_score_and_player(args) do 330 | Multi.new() 331 | |> Multi.insert(:player, Player.changeset(%Player{}, %{name: args.name})) 332 | |> Multi.insert(:score, fn %{player: player} -> Score.changeset(%Score{player_id: player.id}, args) end) 333 | |> Repo.transaction() 334 | |> case do 335 | {:ok, %{score: score}} -> 336 | {:ok, score} 337 | 338 | {:error, _, changeset, _} -> 339 | {:error, changeset} 340 | end 341 | end 342 | 343 | @doc """ 344 | Updates a score. 345 | 346 | ## Examples 347 | 348 | iex> update_score(score, %{field: new_value}) 349 | {:ok, %Score{}} 350 | 351 | iex> update_score(score, %{field: bad_value}) 352 | {:error, %Ecto.Changeset{}} 353 | 354 | """ 355 | def update_score(%Score{} = score, attrs) do 356 | score 357 | |> Score.changeset(attrs) 358 | |> Repo.update() 359 | end 360 | 361 | @doc """ 362 | Deletes a Score. 363 | 364 | ## Examples 365 | 366 | iex> delete_score(score) 367 | {:ok, %Score{}} 368 | 369 | iex> delete_score(score) 370 | {:error, %Ecto.Changeset{}} 371 | 372 | """ 373 | def delete_score(%Score{} = score) do 374 | Repo.delete(score) 375 | end 376 | 377 | @doc """ 378 | Returns an `%Ecto.Changeset{}` for tracking score changes. 379 | 380 | ## Examples 381 | 382 | iex> change_score(score) 383 | %Ecto.Changeset{source: %Score{}} 384 | 385 | """ 386 | def change_score(%Score{} = score) do 387 | Score.changeset(score, %{}) 388 | end 389 | end 390 | --------------------------------------------------------------------------------