├── priv ├── repo │ ├── migrations │ │ ├── .gitkeep │ │ └── 1696952524_add_telemetry_ui_events_table.exs │ ├── dummy.exs │ └── seeds.exs ├── static │ └── favicon.svg └── gettext │ └── en │ └── LC_MESSAGES │ └── errors.po ├── assets ├── .prettierignore ├── prettier.config.js ├── js │ └── app.ts ├── package.json ├── eslint.config.js └── css │ └── app.css ├── .tool-versions ├── lib ├── elixir_boilerplate_web │ ├── home │ │ ├── templates │ │ │ ├── message.html.heex │ │ │ ├── index.html.heex │ │ │ ├── index_live.html.heex │ │ │ └── header.html.heex │ │ ├── controller.ex │ │ ├── html.ex │ │ └── live.ex │ ├── errors │ │ ├── templates │ │ │ ├── error_messages.html.heex │ │ │ └── 404.html.heex │ │ └── errors.ex │ ├── socket.ex │ ├── layouts │ │ ├── templates │ │ │ ├── flash.html.heex │ │ │ ├── app.html.heex │ │ │ ├── live.html.heex │ │ │ └── root.html.heex │ │ └── layouts.ex │ ├── session.ex │ ├── router.ex │ ├── plugs │ │ └── security.ex │ └── endpoint.ex ├── elixir_boilerplate │ ├── gettext.ex │ ├── repo.ex │ ├── schema.ex │ ├── release.ex │ ├── application.ex │ ├── errors │ │ └── sentry.ex │ ├── config.ex │ └── telemetry_ui │ │ └── telemetry_ui.ex ├── elixir_boilerplate.ex ├── elixir_boilerplate_graphql │ ├── plugs │ │ └── context.ex │ ├── middleware │ │ ├── operation_name_logger.ex │ │ └── error_reporting.ex │ ├── application │ │ └── types.ex │ ├── router.ex │ ├── schema.ex │ └── elixir_boilerplate_graphql.ex └── elixir_boilerplate_health │ ├── elixir_boilerplate_health.ex │ └── router.ex ├── rel ├── overlays │ └── bin │ │ ├── server │ │ └── migrate └── vm.args.eex ├── .formatter.exs ├── test ├── test_helper.exs ├── elixir_boilerplate │ ├── gettext_interpolation_test.exs │ └── factory_test.exs ├── elixir_boilerplate_web │ ├── ping_test.exs │ ├── health_test.exs │ ├── home │ │ └── controller_test.exs │ └── errors_test.exs └── support │ ├── factory.ex │ ├── channel_case.ex │ ├── conn_case.ex │ ├── data_case.ex │ └── gettext_interpolation.ex ├── .dockerignore ├── .sobelow-conf ├── coveralls.json ├── package.json ├── accent.json ├── .github ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── accent.yaml │ └── ci.yaml ├── .gitignore ├── config ├── prod.exs ├── dev.exs ├── test.exs ├── config.exs └── runtime.exs ├── docker-compose.yml ├── CONTRIBUTING.md ├── LICENSE.md ├── .env.test ├── CODE_OF_CONDUCT.md ├── .env.dev ├── Dockerfile ├── docs └── module-naming.fr.md ├── CHANGELOG.md ├── mix.exs ├── Makefile ├── boilerplate-setup.sh ├── BOILERPLATE_README.md ├── BOILERPLATE_README.fr.md ├── README.md ├── .credo.exs └── mix.lock /priv/repo/migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.3.2 2 | elixir 1.18.3-otp-27 3 | nodejs 22.17.1 4 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/home/templates/message.html.heex: -------------------------------------------------------------------------------- 1 |

Message: <%= @text %>

2 | -------------------------------------------------------------------------------- /rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | PHX_SERVER=true exec ./elixir_boilerplate start 4 | -------------------------------------------------------------------------------- /assets/prettier.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | singleQuote: true, 3 | bracketSpacing: false, 4 | trailingComma: 'none' 5 | }; 6 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["*.exs", "{config,lib,priv,rel,test}/**/*.{ex,exs}"], 3 | line_length: 180, 4 | plugins: [Styler] 5 | ] 6 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/home/templates/index.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <.header/> 3 | <.message text={@message}/> 4 |
5 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | exec ./elixir_boilerplate eval ElixirBoilerplate.Release.migrate 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | {:ok, _} = Application.ensure_all_started(:ex_machina) 2 | 3 | ExUnit.start() 4 | 5 | Ecto.Adapters.SQL.Sandbox.mode(ElixirBoilerplate.Repo, :manual) 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | .git/ 3 | assets/node_modules/ 4 | deps/ 5 | test/ 6 | priv/plts 7 | priv/static/assets/ 8 | 9 | .* 10 | docker-compose.yml 11 | Makefile 12 | README.md 13 | -------------------------------------------------------------------------------- /.sobelow-conf: -------------------------------------------------------------------------------- 1 | [ 2 | verbose: true, 3 | private: false, 4 | skip: true, 5 | exit: "low", 6 | format: "txt", 7 | threshold: "low", 8 | ignore: ["Config.HTTPS", "Config.Headers"] 9 | ] 10 | -------------------------------------------------------------------------------- /priv/static/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/errors/templates/error_messages.html.heex: -------------------------------------------------------------------------------- 1 | <%= if @errors != [] do %> 2 | 7 | <% end %> 8 | -------------------------------------------------------------------------------- /test/elixir_boilerplate/gettext_interpolation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.GettextInterpolationTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest ElixirBoilerplate.GettextInterpolation, import: true 5 | end 6 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/socket.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.Socket do 2 | use Phoenix.Socket 3 | 4 | def connect(_params, socket) do 5 | {:ok, socket} 6 | end 7 | 8 | def id(_socket), do: nil 9 | end 10 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "coverage_options": { 3 | "minimum_coverage": 30 4 | }, 5 | "skip_files": [ 6 | "lib/elixir-boilerplate/release_tasks.ex", 7 | "lib/mix/tasks/check.erlang_version.ex", 8 | "test/support" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/layouts/templates/flash.html.heex: -------------------------------------------------------------------------------- 1 |
to_string(@kind)} 4 | phx-click={hide_flash("#" <> "flash-" <> to_string(@kind))} 5 | > 6 | <%= msg %> 7 |
8 | -------------------------------------------------------------------------------- /priv/repo/dummy.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database with disposable, development-oriented data. You can run it as: 2 | # 3 | # mix run priv/repo/dummy.exs 4 | # 5 | # We recommend using test factories here, since they already provide good sample data. 6 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.Gettext do 2 | @moduledoc """ 3 | This module manages everything related to the translations used in the 4 | application. 5 | """ 6 | 7 | use Gettext.Backend, otp_app: :elixir_boilerplate 8 | end 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elixir-boilerplate", 3 | "private": true, 4 | "engine-strict": true, 5 | "engines": { 6 | "node": "^18.16.0", 7 | "npm": "^9.5.1" 8 | }, 9 | "devDependencies": { 10 | "accent-cli": "^0.13.3" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/elixir_boilerplate_web/ping_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.PingTest do 2 | use ElixirBoilerplateWeb.ConnCase 3 | 4 | test "GET /ping", %{conn: conn} do 5 | conn = get(conn, "/ping") 6 | 7 | assert response(conn, 200) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /accent.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiUrl": "", 3 | "apiKey": "", 4 | "files": [ 5 | { 6 | "format": "gettext", 7 | "source": "priv/gettext/en/LC_MESSAGES/*.po", 8 | "target": "priv/gettext/%slug%/LC_MESSAGES/%document_path%.po" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /test/elixir_boilerplate_web/health_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.HealthTest do 2 | use ElixirBoilerplateWeb.ConnCase 3 | 4 | test "GET /health", %{conn: conn} do 5 | conn = get(conn, "/health") 6 | 7 | assert response(conn, 200) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/elixir_boilerplate_web/home/controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.Home.ControllerTest do 2 | use ElixirBoilerplateWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, "/") 6 | assert html_response(conn, 200) =~ "Hello, world!" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/support/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.Factory do 2 | @moduledoc false 3 | use ExMachina.Ecto, repo: ElixirBoilerplate.Repo 4 | 5 | # This is a sample factory to make sure our setup is working correctly. 6 | def name_factory(_) do 7 | Faker.Person.name() 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/layouts/templates/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 | <.flash flash={@flash} kind={:success} /> 4 | <.flash flash={@flash} kind={:error} /> 5 | <.flash flash={@flash} kind={:info} /> 6 |
7 | 8 | <%= @inner_content %> 9 |
10 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/layouts/templates/live.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 | <.flash flash={@flash} kind={:success} /> 4 | <.flash flash={@flash} kind={:error} /> 5 | <.flash flash={@flash} kind={:info} /> 6 |
7 | 8 | <%= @inner_content %> 9 |
10 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate do 2 | @moduledoc """ 3 | ElixirBoilerplate 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 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_graphql/plugs/context.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateGraphQL.Plugs.Context do 2 | @moduledoc false 3 | @behaviour Plug 4 | 5 | import Plug.Conn 6 | 7 | def init(opts), do: opts 8 | 9 | def call(conn, _), do: put_private(conn, :absinthe, %{context: build_context(conn)}) 10 | 11 | defp build_context(_conn), do: %{} 12 | end 13 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/home/controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.Home.Controller do 2 | use Phoenix.Controller, formats: [] 3 | 4 | plug(:put_view, ElixirBoilerplateWeb.Home.HTML) 5 | 6 | @spec index(Plug.Conn.t(), map) :: Plug.Conn.t() 7 | def index(conn, _) do 8 | render(conn, "index.html", message: "Hello, world!") 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "mix" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "npm" 8 | directory: "/assets" 9 | schedule: 10 | interval: "weekly" 11 | - package-ecosystem: "docker" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/errors/templates/404.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Not found 7 | 8 | 9 |

Sorry, the page you are looking for does not exist.

10 | 11 | 12 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_graphql/middleware/operation_name_logger.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateGraphQL.Middleware.OperationNameLogger do 2 | @moduledoc false 3 | def run(blueprint, _opts) do 4 | operation_name = Absinthe.Blueprint.current_operation(blueprint).name || "#NULL" 5 | Logger.metadata(graphql_operation_name: operation_name) 6 | 7 | {:ok, blueprint} 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_health/elixir_boilerplate_health.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateHealth do 2 | @moduledoc false 3 | @health_check_error_code 422 4 | 5 | def checks do 6 | [ 7 | %PlugCheckup.Check{name: "NOOP", module: __MODULE__, function: :noop_health} 8 | ] 9 | end 10 | 11 | def error_code, do: @health_check_error_code 12 | 13 | def noop_health, do: :ok 14 | end 15 | -------------------------------------------------------------------------------- /rel/vm.args.eex: -------------------------------------------------------------------------------- 1 | ## Customize flags given to the VM: http://erlang.org/doc/man/erl.html 2 | ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here 3 | 4 | ## Number of dirty schedulers doing IO work (file, sockets, etc) 5 | ##+SDio 5 6 | 7 | ## Increase number of concurrent ports/sockets 8 | ##+Q 65536 9 | 10 | ## Tweak GC to run more often 11 | ##-env ERL_FULLSWEEP_AFTER 10 12 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/home/html.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.Home.HTML do 2 | use Phoenix.Component 3 | 4 | embed_templates("templates/*") 5 | 6 | def render("index.html", assigns), do: index(assigns) 7 | 8 | attr(:text, :string, required: true) 9 | def message(assigns) 10 | 11 | attr(:url, :string, default: "https://github.com/mirego/elixir-boilerplate") 12 | def header(assigns) 13 | end 14 | -------------------------------------------------------------------------------- /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 | # ElixirBoilerplate.Repo.insert!(%ElixirBoilerplate.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 📖 Description 2 | 3 | 4 | 5 | ## 📝 Notes 6 | 7 | 8 | 9 | ## 📓 References 10 | 11 | 12 | 13 | ## 🦀 Dispatch 14 | 15 | - `#dispatch/elixir` 16 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.Repo do 2 | use Ecto.Repo, 3 | adapter: Ecto.Adapters.Postgres, 4 | otp_app: :elixir_boilerplate 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, Application.get_env(:elixir_boilerplate, __MODULE__)[:url])} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/session.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.Session do 2 | @moduledoc false 3 | def config do 4 | [ 5 | store: :cookie, 6 | key: app_config(:session_key), 7 | signing_salt: app_config(:session_signing_salt) 8 | ] 9 | end 10 | 11 | defp app_config(key) do 12 | Keyword.fetch!(Application.get_env(:elixir_boilerplate, ElixirBoilerplateWeb.Endpoint), key) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/elixir_boilerplate/factory_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.FactoryTest do 2 | @moduledoc """ 3 | This is a test module to make sure our factory setup is working correctly. 4 | You’ll probably want to delete it. 5 | """ 6 | 7 | use ElixirBoilerplate.DataCase, async: true 8 | 9 | import ElixirBoilerplate.Factory 10 | 11 | test "build/1 works with our factory setup" do 12 | assert is_binary(build(:name)) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.Schema do 2 | @moduledoc false 3 | defmacro __using__(_) do 4 | quote do 5 | use Ecto.Schema 6 | 7 | import Ecto.Changeset 8 | 9 | alias Ecto.Schema 10 | alias Ecto.UUID 11 | 12 | @primary_key {:id, :binary_id, autogenerate: true} 13 | @foreign_key_type :binary_id 14 | @timestamps_opts [type: :utc_datetime_usec] 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/layouts/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.Layouts do 2 | @moduledoc false 3 | use Phoenix.Component 4 | 5 | alias ElixirBoilerplateWeb.Router.Helpers, as: Routes 6 | alias Phoenix.LiveView.JS 7 | 8 | embed_templates("templates/*") 9 | 10 | attr(:flash, :map, required: true) 11 | attr(:kind, :atom, required: true) 12 | def flash(assigns) 13 | 14 | def hide_flash(id) do 15 | "lv:clear-flash" 16 | |> JS.push() 17 | |> JS.hide(to: id) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | /cover 7 | /tmp 8 | 9 | # Generated on crash by the VM 10 | erl_crash.dump 11 | 12 | # Generated on crash by NPM 13 | npm-debug.log 14 | 15 | # Static artifacts 16 | /assets/node_modules 17 | 18 | # Ignore assets that are produced by build tools 19 | /priv/static/* 20 | !/priv/static/favicon.svg 21 | 22 | # Local environment variable files 23 | .env.local 24 | .env.*.local 25 | 26 | # Sobelow version breadcrumb 27 | .sobelow 28 | 29 | # Ignore Dialyzer’s Persistent Lookup Table (PLT) 30 | /priv/plts/ 31 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_graphql/application/types.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateGraphQL.Application.Types do 2 | @moduledoc false 3 | use Absinthe.Schema.Notation 4 | 5 | object :application do 6 | @desc "The application version" 7 | field(:version, :string) 8 | end 9 | 10 | object :application_queries do 11 | @desc "A list of application information" 12 | field :application, :application do 13 | resolve(fn _, _, _ -> {:ok, %{version: version()}} end) 14 | end 15 | end 16 | 17 | defp version, do: Application.get_env(:elixir_boilerplate, :version) 18 | end 19 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/home/templates/index_live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <.header/> 3 | <.message text={@message}/> 4 | 5 |
6 | 7 |
8 | 9 | <%= @counter %> 10 | 11 |
12 | 13 |
14 | 15 | 16 | 17 |
18 | -------------------------------------------------------------------------------- /priv/repo/migrations/1696952524_add_telemetry_ui_events_table.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.Repo.Migrations.AddTelemetryUiEventsTable do 2 | @moduledoc false 3 | use Ecto.Migration 4 | 5 | alias TelemetryUI.Backend.EctoPostgres.Migrations 6 | 7 | @disable_migration_lock true 8 | @disable_ddl_transaction true 9 | 10 | def up do 11 | Migrations.up() 12 | end 13 | 14 | # We specify `version: 1` in `down`, ensuring that we'll roll all the way back down if 15 | # necessary, regardless of which version we've migrated `up` to. 16 | def down do 17 | Migrations.down(version: 1) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /assets/js/app.ts: -------------------------------------------------------------------------------- 1 | import '../css/app.css'; 2 | 3 | import 'phoenix_html'; 4 | 5 | import * as phoenix from 'phoenix'; 6 | import {LiveSocket} from 'phoenix_live_view'; 7 | 8 | interface Hook { 9 | mounted?(): void; 10 | destroyed?(): void; 11 | } 12 | 13 | const Hooks: Record = {}; 14 | 15 | const csrfToken = document 16 | .querySelector("meta[name='csrf-token']") 17 | ?.getAttribute('content'); 18 | 19 | const liveSocket = new LiveSocket('/live', phoenix.Socket, { 20 | hooks: Hooks, 21 | params: {_csrf_token: csrfToken} // eslint-disable-line camelcase 22 | }); 23 | 24 | liveSocket.connect(); 25 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate/release.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.Release do 2 | @moduledoc false 3 | alias Ecto.Migrator 4 | 5 | @app :elixir_boilerplate 6 | 7 | def migrate do 8 | load_app() 9 | 10 | for repo <- repos() do 11 | {:ok, _, _} = Migrator.with_repo(repo, &Migrator.run(&1, :up, all: true)) 12 | end 13 | end 14 | 15 | def rollback(repo, version) do 16 | load_app() 17 | 18 | {:ok, _, _} = Migrator.with_repo(repo, &Migrator.run(&1, :down, to: version)) 19 | end 20 | 21 | defp repos do 22 | Application.fetch_env!(@app, :ecto_repos) 23 | end 24 | 25 | defp load_app do 26 | Application.load(@app) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :elixir_boilerplate, ElixirBoilerplateWeb.Endpoint, 4 | cache_static_manifest: "priv/static/cache_manifest.json", 5 | debug_errors: false 6 | 7 | config :elixir_boilerplate, :logger, [ 8 | {:handler, :sentry_handler, Sentry.LoggerHandler, 9 | %{ 10 | config: %{ 11 | metadata: [:file, :line], 12 | rate_limiting: [max_events: 10, interval: _1_second = 1_000], 13 | capture_log_messages: true, 14 | level: :error 15 | } 16 | }} 17 | ] 18 | 19 | config :logger, :console, 20 | format: "$time $metadata[$level] $message\n", 21 | level: :info, 22 | metadata: ~w(request_id graphql_operation_name)a 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | application: 3 | image: elixir_boilerplate:0.0.1 4 | container_name: elixir_boilerplate 5 | env_file: .env.dev 6 | environment: 7 | - DATABASE_URL=postgres://postgres:development@postgresql/elixir_boilerplate_dev 8 | ports: 9 | - 4000:4000 10 | depends_on: 11 | - postgresql 12 | postgresql: 13 | image: postgres:14-bookworm 14 | container_name: elixir_boilerplate-postgres 15 | environment: 16 | - POSTGRES_DB=elixir_boilerplate_dev 17 | - POSTGRES_PASSWORD=development 18 | ports: 19 | - 5432:5432 20 | volumes: 21 | - elixir_boilerplate_psql:/var/lib/postgresql/data 22 | volumes: 23 | elixir_boilerplate_psql: 24 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_graphql/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateGraphQL.Router do 2 | use Plug.Router 3 | 4 | defmodule GraphQL do 5 | @moduledoc false 6 | use Plug.Router 7 | 8 | plug(:match) 9 | plug(:dispatch) 10 | 11 | forward("/", 12 | to: Absinthe.Plug, 13 | init_opts: ElixirBoilerplateGraphQL.configuration() 14 | ) 15 | end 16 | 17 | plug(ElixirBoilerplateGraphQL.Plugs.Context) 18 | 19 | plug(:match) 20 | plug(:dispatch) 21 | 22 | # It is intentional that we do not *serve* GraphiQL as part of the API. 23 | # Developers should use standalone GraphQL clients that connect to the API instead. 24 | forward("/graphql", to: GraphQL) 25 | 26 | match(_, do: conn) 27 | end 28 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :elixir_boilerplate, ElixirBoilerplateWeb.Endpoint, 4 | code_reloader: true, 5 | debug_errors: true, 6 | check_origin: false, 7 | watchers: [ 8 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} 9 | ], 10 | live_reload: [ 11 | patterns: [ 12 | ~r{priv/gettext/.*$}, 13 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 14 | ~r{lib/elixir_boilerplate_web/.*(ee?x)$} 15 | ] 16 | ] 17 | 18 | config :elixir_boilerplate, ElixirBoilerplateWeb.Plugs.Security, allow_unsafe_scripts: true 19 | 20 | config :logger, :console, format: "[$level] $message\n" 21 | 22 | config :phoenix, :plug_init_mode, :runtime 23 | config :phoenix, :stacktrace_depth, 20 24 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_health/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateHealth.Router do 2 | use Plug.Router 3 | 4 | defmodule Health do 5 | @moduledoc false 6 | use Plug.Router 7 | 8 | plug(:match) 9 | plug(:dispatch) 10 | 11 | forward( 12 | "/", 13 | to: PlugCheckup, 14 | init_opts: 15 | PlugCheckup.Options.new( 16 | json_encoder: Jason, 17 | checks: ElixirBoilerplateHealth.checks(), 18 | error_code: ElixirBoilerplateHealth.error_code(), 19 | timeout: to_timeout(second: 5), 20 | pretty: false 21 | ) 22 | ) 23 | end 24 | 25 | plug(:match) 26 | plug(:dispatch) 27 | 28 | forward("/health", to: Health) 29 | 30 | match(_, do: conn) 31 | end 32 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.Application do 2 | @moduledoc """ 3 | Main entry point of the app 4 | """ 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | children = [ 10 | ElixirBoilerplate.Repo, 11 | {Phoenix.PubSub, [name: ElixirBoilerplate.PubSub, adapter: Phoenix.PubSub.PG2]}, 12 | ElixirBoilerplateWeb.Endpoint, 13 | {TelemetryUI, ElixirBoilerplate.TelemetryUI.config()} 14 | ] 15 | 16 | Logger.add_handlers(:elixir_boilerplate) 17 | 18 | opts = [strategy: :one_for_one, name: ElixirBoilerplate.Supervisor] 19 | Supervisor.start_link(children, opts) 20 | end 21 | 22 | def config_change(changed, _new, removed) do 23 | ElixirBoilerplateWeb.Endpoint.config_change(changed, removed) 24 | :ok 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/layouts/templates/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= assigns[:page_title] || "ElixirBoilerplate" %> 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | <%= @inner_content %> 18 | 19 | 20 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elixir-boilerplate", 3 | "version": "0.0.1", 4 | "private": true, 5 | "type": "module", 6 | "engine-strict": true, 7 | "engines": { 8 | "node": "^22.17.0", 9 | "npm": "^10.9.0" 10 | }, 11 | "dependencies": { 12 | "globals": "^16.4.0", 13 | "phoenix": "file:../deps/phoenix", 14 | "phoenix_html": "file:../deps/phoenix_html", 15 | "phoenix_live_view": "file:../deps/phoenix_live_view" 16 | }, 17 | "devDependencies": { 18 | "@babel/eslint-parser": "^7.26.0", 19 | "@eslint/js": "^9.17.0", 20 | "@types/phoenix": "^1.6.6", 21 | "@types/phoenix_live_view": "^1.0.0", 22 | "@typescript-eslint/eslint-plugin": "^8.45.0", 23 | "@typescript-eslint/parser": "^8.45.0", 24 | "eslint": "^9.17.0", 25 | "eslint-plugin-mirego": "^1.0.0", 26 | "prettier": "^3.4.2", 27 | "typescript": "^5.9.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_graphql/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateGraphQL.Schema do 2 | @moduledoc false 3 | use Absinthe.Schema 4 | 5 | import_types(Absinthe.Type.Custom) 6 | import_types(ElixirBoilerplateGraphQL.Application.Types) 7 | 8 | query do 9 | import_fields(:application_queries) 10 | end 11 | 12 | # Having an empty mutation block is invalid and raises an error in Absinthe. 13 | # Uncomment it when you add the first mutation. 14 | # 15 | # mutation do 16 | # end 17 | 18 | def context(context) do 19 | Map.put(context, :loader, Dataloader.add_source(Dataloader.new(), :repo, Dataloader.Ecto.new(ElixirBoilerplate.Repo))) 20 | end 21 | 22 | def plugins do 23 | [Absinthe.Middleware.Dataloader] ++ Absinthe.Plugin.defaults() 24 | end 25 | 26 | def middleware(middleware, _, _) do 27 | [NewRelic.Absinthe.Middleware] ++ middleware 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | defmodule TestEnvironment do 4 | @moduledoc false 5 | @database_name_suffix "_test" 6 | 7 | def get_database_url do 8 | url = System.get_env("DATABASE_URL") 9 | 10 | if is_nil(url) || String.ends_with?(url, @database_name_suffix) do 11 | url 12 | else 13 | raise "Expected database URL to end with '#{@database_name_suffix}', got: #{url}" 14 | end 15 | end 16 | end 17 | 18 | # This config is to output keys instead of translated message in test 19 | config :elixir_boilerplate, ElixirBoilerplate.Gettext, priv: "priv/null", interpolation: ElixirBoilerplate.GettextInterpolation 20 | 21 | config :elixir_boilerplate, ElixirBoilerplate.Repo, 22 | pool: Ecto.Adapters.SQL.Sandbox, 23 | url: TestEnvironment.get_database_url() 24 | 25 | config :elixir_boilerplate, ElixirBoilerplateWeb.Endpoint, server: false 26 | 27 | config :logger, level: :warning 28 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_graphql/middleware/error_reporting.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateGraphQL.Middleware.ErrorReporting do 2 | @moduledoc false 3 | defmodule Error do 4 | @moduledoc false 5 | defexception [:message] 6 | end 7 | 8 | def run(%{result: %{errors: errors}, source: source} = blueprint, options) when not is_nil(errors) do 9 | Sentry.capture_exception( 10 | %Error{ 11 | message: "Invalid GraphQL response" 12 | }, 13 | extra: %{ 14 | operation_name: operation_name(Absinthe.Blueprint.current_operation(blueprint)), 15 | variables: Keyword.get(options, :variables, %{}), 16 | errors: errors, 17 | source: source 18 | } 19 | ) 20 | 21 | {:ok, blueprint} 22 | end 23 | 24 | def run(blueprint, _) do 25 | {:ok, blueprint} 26 | end 27 | 28 | defp operation_name(nil), do: nil 29 | defp operation_name(operation), do: operation.name 30 | end 31 | -------------------------------------------------------------------------------- /assets/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import tsParser from '@typescript-eslint/parser'; 3 | import tsPlugin from '@typescript-eslint/eslint-plugin'; 4 | import miregoPlugin from 'eslint-plugin-mirego'; 5 | import globals from 'globals'; 6 | 7 | export default [ 8 | js.configs.recommended, 9 | { 10 | ignores: ['node_modules/*', '**/static/*.js', 'static/**/*.js'] 11 | }, 12 | { 13 | files: ['**/*.ts'], 14 | languageOptions: { 15 | ecmaVersion: 2021, 16 | sourceType: 'module', 17 | parser: tsParser, 18 | parserOptions: { 19 | project: null 20 | }, 21 | globals: { 22 | ...globals.browser, 23 | ...globals.es2021 24 | } 25 | }, 26 | plugins: { 27 | '@typescript-eslint': tsPlugin, 28 | mirego: miregoPlugin 29 | }, 30 | rules: { 31 | ...tsPlugin.configs.recommended.rules, 32 | ...miregoPlugin.configs.recommended.rules 33 | } 34 | } 35 | ]; 36 | -------------------------------------------------------------------------------- /.github/workflows/accent.yaml: -------------------------------------------------------------------------------- 1 | name: Accent 2 | 3 | on: 4 | schedule: 5 | - cron: "0 4 * * *" 6 | 7 | jobs: 8 | sync: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 16 15 | - run: npm install -g accent-cli 16 | - run: accent sync --add-translations --merge-type=passive --order-by=key 17 | - uses: mirego/create-pull-request@v5 18 | with: 19 | add-paths: "*.po" 20 | commit-message: Update translations 21 | committer: github-actions[bot] 22 | author: github-actions[bot] 23 | branch: accent 24 | draft: false 25 | delete-branch: true 26 | title: New translations are available to merge 27 | body: The translation files have been updated, feel free to merge this pull request after review. 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | ## A word about the project 4 | 5 | First of all, thank you for your interest in contributing to this project! 6 | 7 | This project is our vision of a well set up project and is the base we use to create all of our Elixir applications at Mirego. We decided to make it public so that others can benefit from our experience and the lessons learned over the years of building several projects with different objectives; all fulfilled by this boilerplate. 8 | 9 | While we accept pull requests and suggestions, it is more of a project that we want to share so that you can build awesome things with it and maybe, base your own boilerplate off of it! 10 | 11 | ## Contributing 12 | 13 | We strongly suggest you open an issue before starting to work on code that you would like to see in this project. This will prevent you, for example, from implementing a feature that we, Mirego, already discussed and decided not to use. 14 | 15 | Bug and typo fixes are always welcomed, of course 🙂 16 | 17 | Thank you! ❤️ 18 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.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 | alias Ecto.Adapters.SQL.Sandbox 19 | alias ElixirBoilerplate.Repo 20 | alias ElixirBoilerplateWeb.Endpoint 21 | 22 | using do 23 | quote do 24 | # Import conveniences for testing with channels 25 | use Phoenix.ChannelTest 26 | 27 | # The default endpoint for testing 28 | @endpoint Endpoint 29 | end 30 | end 31 | 32 | setup tags do 33 | :ok = Sandbox.checkout(Repo) 34 | 35 | if !tags[:async] do 36 | Sandbox.mode(Repo, {:shared, self()}) 37 | end 38 | 39 | :ok 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 3 | system-ui, 4 | -apple-system, 5 | 'Segoe UI', 6 | Roboto, 7 | 'Helvetica Neue', 8 | Arial, 9 | 'Noto Sans', 10 | 'Liberation Sans', 11 | sans-serif, 12 | 'Apple Color Emoji', 13 | 'Segoe UI Emoji', 14 | 'Segoe UI Symbol', 15 | 'Noto Color Emoji'; 16 | } 17 | 18 | .home { 19 | display: flex; 20 | flex-direction: column; 21 | justify-content: center; 22 | align-items: center; 23 | padding: 40px; 24 | text-align: center; 25 | line-height: 1.4; 26 | } 27 | 28 | .home a { 29 | display: block; 30 | margin: 0 0 20px; 31 | } 32 | 33 | .home p { 34 | margin: 0 0 20px; 35 | } 36 | 37 | .home p:last-child { 38 | margin-bottom: 0; 39 | } 40 | 41 | .flash-messages { 42 | display: flex; 43 | position: fixed; 44 | z-index: 1000; 45 | top: 5px; 46 | right: 5px; 47 | flex-direction: column; 48 | gap: 5px; 49 | } 50 | 51 | #flash-success { 52 | padding: 10px; 53 | border: 1px solid rgb(62, 146, 62); 54 | background-color: #90d690; 55 | color: #123c12; 56 | } 57 | 58 | #flash-error { 59 | padding: 10px; 60 | border: 1px solid rgb(144, 35, 35); 61 | background-color: #d68d8d; 62 | color: #3c1212; 63 | } 64 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/home/live.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.Home.Live do 2 | @moduledoc false 3 | use Phoenix.LiveView, layout: {ElixirBoilerplateWeb.Layouts, :live} 4 | 5 | on_mount(Sentry.LiveViewHook) 6 | 7 | def mount(_, _, socket) do 8 | socket = assign(socket, :message, "Hello, world!") 9 | socket = assign(socket, :counter, 0) 10 | socket = assign(socket, :page_title, "Home") 11 | 12 | {:ok, socket} 13 | end 14 | 15 | def render(assigns), do: ElixirBoilerplateWeb.Home.HTML.index_live(assigns) 16 | 17 | def handle_event("increment_counter", _, socket) do 18 | socket = assign(socket, :counter, socket.assigns.counter + 1) 19 | {:noreply, socket} 20 | end 21 | 22 | def handle_event("decrement_counter", _, socket) do 23 | socket = assign(socket, :counter, socket.assigns.counter - 1) 24 | {:noreply, socket} 25 | end 26 | 27 | def handle_event("add_flash_success", _, socket) do 28 | socket = put_flash(socket, :success, "Success: #{DateTime.utc_now()}") 29 | {:noreply, socket} 30 | end 31 | 32 | def handle_event("add_flash_error", _, socket) do 33 | socket = put_flash(socket, :error, "Error: #{DateTime.utc_now()}") 34 | {:noreply, socket} 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.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 | alias Ecto.Adapters.SQL.Sandbox 19 | alias ElixirBoilerplate.Repo 20 | alias ElixirBoilerplateWeb.Endpoint 21 | alias Phoenix.ConnTest 22 | 23 | using do 24 | quote do 25 | # Import conveniences for testing with connections 26 | import ElixirBoilerplateWeb.Router.Helpers 27 | import Phoenix.ConnTest 28 | import Plug.Conn 29 | 30 | # The default endpoint for testing 31 | @endpoint Endpoint 32 | end 33 | end 34 | 35 | setup tags do 36 | :ok = Sandbox.checkout(Repo) 37 | 38 | if !tags[:async] do 39 | Sandbox.mode(Repo, {:shared, self()}) 40 | end 41 | 42 | {:ok, conn: %{ConnTest.build_conn() | host: host()}} 43 | end 44 | 45 | defp host, do: Application.get_env(:elixir_boilerplate, :canonical_host) 46 | end 47 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate/errors/sentry.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.Errors.Sentry do 2 | @moduledoc false 3 | @scrubbed_keys ["first_name", "last_name", "email"] 4 | @scrubbed_value "*********" 5 | 6 | def scrub_params(conn) do 7 | conn 8 | |> Sentry.PlugContext.default_body_scrubber() 9 | |> scrub_map(@scrubbed_keys) 10 | end 11 | 12 | def scrubbed_remote_address(_conn), do: @scrubbed_value 13 | 14 | # Reference: https://github.com/getsentry/sentry-elixir/blob/9.1.0/lib/sentry/plug_context.ex#L232 15 | defp scrub_map(map, scrubbed_keys) do 16 | Map.new(map, fn {key, value} -> 17 | value = 18 | cond do 19 | key in scrubbed_keys -> @scrubbed_value 20 | is_struct(value) -> value |> Map.from_struct() |> scrub_map(scrubbed_keys) 21 | is_map(value) -> scrub_map(value, scrubbed_keys) 22 | is_list(value) -> scrub_list(value, scrubbed_keys) 23 | true -> value 24 | end 25 | 26 | {key, value} 27 | end) 28 | end 29 | 30 | # Reference: https://github.com/getsentry/sentry-elixir/blob/9.1.0/lib/sentry/plug_context.ex#L248 31 | defp scrub_list(list, scrubbed_keys) do 32 | Enum.map(list, fn value -> 33 | cond do 34 | is_struct(value) -> value |> Map.from_struct() |> scrub_map(scrubbed_keys) 35 | is_map(value) -> scrub_map(value, scrubbed_keys) 36 | is_list(value) -> scrub_list(value, scrubbed_keys) 37 | true -> value 38 | end 39 | end) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2020, Mirego 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | - Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | - Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | - Neither the name of the Mirego nor the names of its contributors may 13 | be used to endorse or promote products derived from this software without 14 | specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 19 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 20 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 21 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 22 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 23 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 24 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 25 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.Router do 2 | use Phoenix.Router 3 | 4 | import Phoenix.LiveView.Router 5 | 6 | pipeline :browser do 7 | plug(:accepts, ["html", "json"]) 8 | 9 | plug(:session) 10 | plug(:fetch_session) 11 | 12 | plug(:protect_from_forgery) 13 | plug(:put_secure_browser_headers) 14 | plug(:fetch_live_flash) 15 | 16 | plug(:put_layout, {ElixirBoilerplateWeb.Layouts, :app}) 17 | plug(:put_root_layout, {ElixirBoilerplateWeb.Layouts, :root}) 18 | end 19 | 20 | scope "/" do 21 | pipe_through(:browser) 22 | 23 | # To enable metrics dashboard use `telemetry_ui_allowed: true` as assigns value 24 | # 25 | # Metrics can contains sensitive data you should protect it under authorization 26 | # See https://github.com/mirego/telemetry_ui#security 27 | get("/metrics", TelemetryUI.Web, [], assigns: %{telemetry_ui_allowed: false}) 28 | end 29 | 30 | scope "/", ElixirBoilerplateWeb do 31 | pipe_through(:browser) 32 | 33 | get("/", Home.Controller, :index, as: :home) 34 | end 35 | 36 | scope "/", ElixirBoilerplateWeb do 37 | pipe_through(:browser) 38 | 39 | live("/live", Home.Live, :index, as: :live_home) 40 | end 41 | 42 | # The session will be stored in the cookie and signed, 43 | # this means its contents can be read but not tampered with. 44 | # Set :encryption_salt if you would also like to encrypt it. 45 | defp session(conn, _opts) do 46 | opts = Plug.Session.init(ElixirBoilerplateWeb.Session.config()) 47 | Plug.Session.call(conn, opts) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - "**" 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | ci: 17 | runs-on: ubuntu-latest 18 | 19 | services: 20 | db: 21 | image: postgres:14 22 | env: 23 | POSTGRES_DB: elixir_boilerplate_test 24 | POSTGRES_PASSWORD: development 25 | ports: ["5432:5432"] 26 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 27 | 28 | env: 29 | MIX_ENV: test 30 | 31 | steps: 32 | - uses: actions/checkout@v3 33 | - uses: docker/setup-buildx-action@v2 34 | 35 | - uses: erlef/setup-beam@v1 36 | id: setup-beam 37 | with: 38 | version-file: .tool-versions 39 | version-type: strict 40 | 41 | - uses: actions/cache@v3 42 | with: 43 | path: | 44 | deps 45 | _build 46 | priv/plts 47 | key: ${{ runner.os }}-mix-${{ steps.setup-beam.outputs.otp-version }}-${{ steps.setup-beam.outputs.elixir-version }}-${{ hashFiles(format('{0}/mix.lock', github.workspace)) }} 48 | 49 | - uses: actions/setup-node@v3 50 | with: 51 | node-version-file: .tool-versions 52 | cache: npm 53 | 54 | - run: grep -v '^\(#.*\|\s\?\)$' .env.test >> $GITHUB_ENV 55 | 56 | - run: make prepare 57 | - run: make lint 58 | - run: make check 59 | - run: make build DOCKER_IMAGE_TAG=latest 60 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_graphql/elixir_boilerplate_graphql.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateGraphQL do 2 | @moduledoc false 3 | 4 | alias Absinthe.Phase.Document.Complexity.Result 5 | alias Absinthe.Pipeline 6 | alias ElixirBoilerplateGraphQL.Middleware 7 | 8 | def configuration do 9 | [ 10 | document_providers: [Absinthe.Plug.DocumentProvider.Default], 11 | json_codec: Phoenix.json_library(), 12 | schema: ElixirBoilerplateGraphQL.Schema, 13 | pipeline: {__MODULE__, :absinthe_pipeline} 14 | ] 15 | end 16 | 17 | def absinthe_pipeline(config, options) do 18 | options = build_options(options) 19 | 20 | config 21 | |> Absinthe.Plug.default_pipeline(options) 22 | |> Pipeline.insert_after(Result, {AbsintheSecurity.Phase.IntrospectionCheck, options}) 23 | |> Pipeline.insert_after(Result, {AbsintheSecurity.Phase.MaxAliasesCheck, options}) 24 | |> Pipeline.insert_after(Result, {AbsintheSecurity.Phase.MaxDepthCheck, options}) 25 | |> Pipeline.insert_after(Result, {AbsintheSecurity.Phase.MaxDirectivesCheck, options}) 26 | |> Pipeline.insert_before(Absinthe.Phase.Document.Result, Middleware.OperationNameLogger) 27 | |> Pipeline.insert_after(Absinthe.Phase.Document.Result, {AbsintheSecurity.Phase.FieldSuggestionsCheck, options}) 28 | |> Pipeline.insert_after(Absinthe.Phase.Document.Result, Middleware.ErrorReporting) 29 | end 30 | 31 | defp build_options(options) do 32 | Keyword.merge( 33 | [ 34 | token_limit: Application.get_env(:elixir_boilerplate, ElixirBoilerplateGraphQL)[:token_limit] 35 | ], 36 | Pipeline.options(options) 37 | ) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.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 | alias Ecto.Adapters.SQL.Sandbox 18 | alias Ecto.Changeset 19 | alias ElixirBoilerplate.Repo 20 | 21 | using do 22 | quote do 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import ElixirBoilerplate.DataCase 27 | 28 | alias ElixirBoilerplate.Repo 29 | end 30 | end 31 | 32 | setup tags do 33 | :ok = Sandbox.checkout(Repo) 34 | 35 | if !tags[:async] do 36 | Sandbox.mode(Repo, {:shared, self()}) 37 | end 38 | 39 | :ok 40 | end 41 | 42 | @doc """ 43 | A helper that transform changeset errors to a map of messages. 44 | 45 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 46 | assert "password is too short" in errors_on(changeset).password 47 | assert %{password: ["password is too short"]} = errors_on(changeset) 48 | 49 | """ 50 | def errors_on(changeset) do 51 | Changeset.traverse_errors(changeset, fn {message, opts} -> 52 | Enum.reduce(opts, message, fn {key, value}, acc -> 53 | String.replace(acc, "%{#{key}}", to_string(value)) 54 | end) 55 | end) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/plugs/security.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.Plugs.Security do 2 | @moduledoc false 3 | @behaviour Plug 4 | 5 | import Phoenix.Controller, only: [put_secure_browser_headers: 2] 6 | 7 | @doc """ 8 | This plug adds Phoenix secure HTTP headers including a 9 | “Content-Security-Policy” header to responses.You will need to customize each 10 | policy directive to fit your application needs. 11 | """ 12 | 13 | def init(opts), do: opts 14 | 15 | def call(conn, _) do 16 | directives = [ 17 | "default-src #{default_src_directive()}", 18 | "form-action #{form_action_directive()}", 19 | "media-src #{media_src_directive()}", 20 | "img-src #{image_src_directive()}", 21 | "script-src #{script_src_directive()}", 22 | "font-src #{font_src_directive()}", 23 | "connect-src #{connect_src_directive()}", 24 | "style-src #{style_src_directive()}", 25 | "frame-src #{frame_src_directive()}" 26 | ] 27 | 28 | put_secure_browser_headers(conn, %{"content-security-policy" => Enum.join(directives, "; ")}) 29 | end 30 | 31 | defp default_src_directive, do: "'none'" 32 | defp form_action_directive, do: "'self'" 33 | defp media_src_directive, do: "'self'" 34 | defp font_src_directive, do: "'self'" 35 | defp connect_src_directive, do: "'self'" 36 | defp style_src_directive, do: "'self' 'unsafe-inline'" 37 | defp frame_src_directive, do: "'self'" 38 | defp image_src_directive, do: "'self' data:" 39 | 40 | defp script_src_directive do 41 | if Application.get_env(:elixir_boilerplate, __MODULE__)[:allow_unsafe_scripts] do 42 | "'self' 'unsafe-eval' 'unsafe-inline'" 43 | else 44 | "'self' 'unsafe-inline'" 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | version = Mix.Project.config()[:version] 4 | 5 | config :absinthe_security, AbsintheSecurity.Phase.MaxAliasesCheck, max_alias_count: 100 6 | config :absinthe_security, AbsintheSecurity.Phase.MaxDepthCheck, max_depth_count: 100 7 | config :absinthe_security, AbsintheSecurity.Phase.MaxDirectivesCheck, max_directive_count: 100 8 | 9 | config :elixir_boilerplate, Corsica, allow_headers: :all 10 | config :elixir_boilerplate, ElixirBoilerplate.Gettext, default_locale: "en" 11 | 12 | config :elixir_boilerplate, ElixirBoilerplate.Repo, 13 | migration_primary_key: [type: :binary_id, default: {:fragment, "gen_random_uuid()"}], 14 | migration_timestamps: [type: :utc_datetime_usec], 15 | start_apps_before_migration: [:ssl] 16 | 17 | config :elixir_boilerplate, ElixirBoilerplateGraphQL, token_limit: 2000 18 | 19 | config :elixir_boilerplate, ElixirBoilerplateWeb.Endpoint, 20 | pubsub_server: ElixirBoilerplate.PubSub, 21 | render_errors: [view: ElixirBoilerplateWeb.Errors, accepts: ~w(html json)] 22 | 23 | config :elixir_boilerplate, ElixirBoilerplateWeb.Plugs.Security, allow_unsafe_scripts: false 24 | 25 | config :elixir_boilerplate, 26 | ecto_repos: [ElixirBoilerplate.Repo], 27 | version: version 28 | 29 | config :esbuild, 30 | version: "0.16.4", 31 | default: [ 32 | args: ~w(js/app.ts --bundle --target=es2020 --outdir=../priv/static/assets), 33 | cd: Path.expand("../assets", __DIR__), 34 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 35 | ] 36 | 37 | config :logger, backends: [:console, Sentry.LoggerBackend] 38 | 39 | # Import environment configuration 40 | config :phoenix, :json_library, Jason 41 | 42 | config :sentry, 43 | root_source_code_path: File.cwd!(), 44 | release: version 45 | 46 | import_config "#{Mix.env()}.exs" 47 | -------------------------------------------------------------------------------- /test/support/gettext_interpolation.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.GettextInterpolation do 2 | @moduledoc """ 3 | Default Gettext.Interpolation implementation for testing purposes 4 | 5 | Appends formatted `bindings` at the end of the string 6 | """ 7 | 8 | @behaviour Gettext.Interpolation 9 | 10 | @doc """ 11 | iex> runtime_interpolate("test", %{}) 12 | {:ok, "test"} 13 | iex> runtime_interpolate("test", %{arg: 1}) 14 | {:ok, "test[arg=1]"} 15 | iex> runtime_interpolate("test", %{arg: :atom}) 16 | {:ok, "test[arg=:atom]"} 17 | iex> runtime_interpolate("test", %{arg: [:atom,:atom2]}) 18 | {:ok, "test[arg=:atom,:atom2]"} 19 | iex> runtime_interpolate("test", %{b: 1, a: [:a, :b]}) 20 | {:ok, "test[a=:a,:b,b=1]"} 21 | """ 22 | @impl true 23 | def runtime_interpolate(message, bindings), do: {:ok, format(message, bindings)} 24 | 25 | @impl true 26 | defmacro compile_interpolate(_message_type, message, bindings) do 27 | quote do 28 | runtime_interpolate(unquote(message), unquote(bindings)) 29 | end 30 | end 31 | 32 | @impl true 33 | def message_format, do: "test-format" 34 | 35 | defp format(message, bindings), do: "#{message}#{format_bindings(bindings)}" 36 | 37 | defp format_bindings(bindings) when is_map(bindings) and map_size(bindings) === 0, do: "" 38 | 39 | defp format_bindings(bindings) when is_map(bindings) do 40 | bindings = 41 | bindings 42 | |> Enum.sort(fn {k1, _}, {k2, _} -> k1 < k2 end) 43 | |> Enum.map_join(",", fn {key, value} -> "#{key}=#{format_value(value)}" end) 44 | 45 | "[#{bindings}]" 46 | end 47 | 48 | defp format_bindings(_bindings), do: "" 49 | 50 | defp format_value(value) when is_list(value), do: Enum.map_join(value, ",", &format_value/1) 51 | defp format_value(value) when is_binary(value), do: value 52 | defp format_value(value), do: inspect(value) 53 | end 54 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # This file contains all the environment variables needed by the test suite. 3 | # It is checked into version control so all developers can share it and use it 4 | # as a base to build their own `.env.test.local` file. 5 | # 6 | # Only variables required by the test suite should be stored in here, other 7 | # variables should be mocked in the suite itself. 8 | # ----------------------------------------------------------------------------- 9 | 10 | # Server configuration 11 | CORS_ALLOWED_ORIGINS=* 12 | DEBUG_ERRORS=true 13 | PORT=4001 14 | SECRET_KEY_BASE=G0ieeRljoXGzSDPRrYc2q4ADyNHCwxNOkw7YpPNMa+JgP9iGgJKT4K96Bw/Mf/pd 15 | SESSION_KEY=elixir_boilerplate 16 | SESSION_SIGNING_SALT=qh+vmMHsOqcjKF3TSSIsghwt2go48m2+IQ+kMTOB3BrSysSr7D4a21uAtt4yp4wn 17 | 18 | # Database configuration 19 | # - Use `postgres://localhost/elixir_boilerplate_test` if you have a local PostgreSQL server 20 | # - Use `postgres://username:password@localhost/elixir_boilerplate_test` if you have a local PostgreSQL server with credentials 21 | # - Use `postgres://postgres:development@localhost/elixir_boilerplate_test` if you’re using the PostgreSQL server provided by Docker Compose 22 | DATABASE_URL=postgres://postgres:development@localhost/elixir_boilerplate_test 23 | DATABASE_POOL_SIZE=5 24 | 25 | # URL configuration (used by Phoenix to build URLs from routes) 26 | # Other features also extracts values from this URL: 27 | # - Redirect to canonical host 28 | # - Force SSL requests 29 | CANONICAL_URL=http://localhost:4001 30 | 31 | # Static URL configuration (used by Phoenix to generate static file URLs, eg. 32 | # CSS and JavaScript). We often use these variables to configure a CDN that 33 | # will cache static files once they have been served by the Phoenix 34 | # application. 35 | STATIC_URL=http://localhost:4001 36 | 37 | # Sentry requires an environment name (but not a DSN) 38 | SENTRY_ENVIRONMENT_NAME=test 39 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate/config.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.Config do 2 | @moduledoc """ 3 | This modules provides various helpers to handle environment variables 4 | """ 5 | 6 | @type value_type :: :string | :integer | :boolean | :uri | :cors 7 | @type config_type :: String.t() | integer() | boolean() | URI.t() | [String.t()] 8 | 9 | @spec get_env(String.t(), nil | value_type()) :: config_type() 10 | def get_env(key, type \\ :string) do 11 | value = System.get_env(key) 12 | 13 | parse_env(value, type) 14 | end 15 | 16 | @spec get_env!(String.t(), nil | value_type()) :: config_type() 17 | def get_env!(key, type \\ :string) do 18 | value = System.fetch_env!(key) 19 | 20 | parse_env(value, type) 21 | end 22 | 23 | defp parse_env(value, :string), do: value 24 | defp parse_env(value, :integer), do: String.to_integer(value) 25 | 26 | defp parse_env(nil, :boolean), do: false 27 | defp parse_env("", :boolean), do: false 28 | defp parse_env(value, :boolean), do: String.downcase(value) in ~w(true 1) 29 | 30 | defp parse_env(nil, :cors), do: nil 31 | 32 | defp parse_env(value, :cors) when is_bitstring(value) do 33 | case String.split(value, ",") do 34 | [origin] -> origin 35 | origins -> origins 36 | end 37 | end 38 | 39 | defp parse_env(nil, :uri), do: nil 40 | defp parse_env("", :uri), do: nil 41 | defp parse_env(value, :uri), do: URI.parse(value) 42 | 43 | @spec get_endpoint_url_config(URI.t() | any()) :: nil | [scheme: String.t(), host: String.t(), port: String.t()] 44 | def get_endpoint_url_config(%URI{scheme: scheme, host: host, port: port}), do: [scheme: scheme, host: host, port: port] 45 | def get_endpoint_url_config(_invalid), do: nil 46 | 47 | @spec get_uri_part(URI.t() | any(), :scheme | :host | :port) :: String.t() | nil 48 | def get_uri_part(%URI{scheme: scheme}, :scheme), do: scheme 49 | def get_uri_part(%URI{host: host}, :host), do: host 50 | def get_uri_part(%URI{port: port}, :port), do: port 51 | def get_uri_part(_invalid, _part), do: nil 52 | end 53 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | import ElixirBoilerplate.Config 3 | 4 | canonical_uri = get_env("CANONICAL_URL", :uri) 5 | static_uri = get_env("STATIC_URL", :uri) 6 | 7 | config :elixir_boilerplate, ElixirBoilerplate.Repo, 8 | url: get_env!("DATABASE_URL"), 9 | ssl: get_env("DATABASE_SSL", :boolean), 10 | pool_size: get_env!("DATABASE_POOL_SIZE", :integer), 11 | socket_options: if(get_env("DATABASE_IPV6", :boolean), do: [:inet6], else: []) 12 | 13 | config :elixir_boilerplate, 14 | canonical_host: get_uri_part(canonical_uri, :host), 15 | force_ssl: get_uri_part(canonical_uri, :scheme) == "https" 16 | 17 | # NOTE: Only set `server` to `true` if `PHX_SERVER` is present. We cannot set 18 | # it to `false` otherwise because `mix phx.server` will stop working without it. 19 | if get_env("PHX_SERVER", :boolean) == true do 20 | config :elixir_boilerplate, ElixirBoilerplateWeb.Endpoint, server: true 21 | end 22 | 23 | config :absinthe_security, AbsintheSecurity.Phase.FieldSuggestionsCheck, enable_field_suggestions: get_env("GRAPHQL_ENABLE_FIELD_SUGGESTIONS", :boolean) 24 | config :absinthe_security, AbsintheSecurity.Phase.IntrospectionCheck, enable_introspection: get_env("GRAPHQL_ENABLE_INTROSPECTION", :boolean) 25 | 26 | config :elixir_boilerplate, Corsica, origins: get_env("CORS_ALLOWED_ORIGINS", :cors) 27 | config :elixir_boilerplate, ElixirBoilerplate.TelemetryUI, share_key: get_env("TELEMETRY_UI_SHARE_KEY") 28 | 29 | config :elixir_boilerplate, ElixirBoilerplateWeb.Endpoint, 30 | http: [port: get_env!("PORT", :integer)], 31 | secret_key_base: get_env!("SECRET_KEY_BASE"), 32 | session_key: get_env!("SESSION_KEY"), 33 | session_signing_salt: get_env!("SESSION_SIGNING_SALT"), 34 | live_view: [signing_salt: get_env!("SESSION_SIGNING_SALT")], 35 | url: get_endpoint_url_config(canonical_uri), 36 | static_url: get_endpoint_url_config(static_uri) 37 | 38 | config :elixir_boilerplate, 39 | basic_auth: [ 40 | username: get_env("BASIC_AUTH_USERNAME"), 41 | password: get_env("BASIC_AUTH_PASSWORD") 42 | ] 43 | 44 | config :sentry, 45 | dsn: get_env("SENTRY_DSN"), 46 | environment_name: get_env("SENTRY_ENVIRONMENT_NAME") 47 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | Contact: info@mirego.com 4 | 5 | ## Why have a Code of Conduct? 6 | 7 | As contributors and maintainers of this project, we are committed to providing a friendly, safe and welcoming environment for all, regardless of age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic. 8 | 9 | The goal of the Code of Conduct is to specify a baseline standard of behavior so that people with different social values and communication styles can talk about the project effectively, productively, and respectfully, even in face of disagreements. The Code of Conduct also provides a mechanism for resolving conflicts in the community when they arise. 10 | 11 | ## Our Values 12 | 13 | These are the values Elixir Boilerplate developers should aspire to: 14 | 15 | - Be friendly and welcoming 16 | - Be patient 17 | - Remember that people have varying communication styles and that not everyone is using their native language. (Meaning and tone can be lost in translation.) 18 | - Be thoughtful 19 | - Productive communication requires effort. Think about how your words will be interpreted. 20 | - Remember that sometimes it is best to refrain entirely from commenting. 21 | - Be respectful 22 | - In particular, respect differences of opinion. It is important that we resolve disagreements and differing views constructively. 23 | - Avoid destructive behavior 24 | - Derailing: stay on topic; if you want to talk about something else, start a new conversation. 25 | - Unconstructive criticism: don't merely decry the current state of affairs; offer (or at least solicit) suggestions as to how things may be improved. 26 | - Snarking (pithy, unproductive, sniping comments). 27 | 28 | The following actions are explicitly forbidden: 29 | 30 | - Insulting, demeaning, hateful, or threatening remarks. 31 | - Discrimination based on age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic. 32 | - Bullying or systematic harassment. 33 | - Unwelcome sexual advances. 34 | - Incitement to any of these. 35 | 36 | ## Acknowledgements 37 | 38 | This document was based on the Code of Conduct from the Elixir project. 39 | -------------------------------------------------------------------------------- /.env.dev: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # This file contains all the environment variables needed (or supported) by the 3 | # application. It is checked into version control so all developers can share 4 | # it and use it as a base to build their own `.env.dev.local` file. 5 | # 6 | # Current project developers should try to fill the values with the most 7 | # generic information possible for future developers. 8 | # 9 | # Personal values (such as access and secret keys) should *not* be stored in 10 | # this file since they’re not shared among developers. 11 | # ----------------------------------------------------------------------------- 12 | 13 | # Server configuration 14 | CORS_ALLOWED_ORIGINS=* 15 | PORT=4000 16 | SECRET_KEY_BASE= # Generate secret with `mix phx.gen.secret` 17 | SESSION_KEY=elixir_boilerplate 18 | SESSION_SIGNING_SALT= # Generate salt with `mix phx.gen.secret` 19 | 20 | # Database configuration 21 | # - Use `postgres://localhost/elixir_boilerplate_dev` if you have a local PostgreSQL server 22 | # - Use `postgres://username:password@localhost/elixir_boilerplate_dev` if you have a local PostgreSQL server with credentials 23 | # - Use `postgres://postgres:development@localhost/elixir_boilerplate_dev` if you’re using the PostgreSQL server provided by Docker Compose 24 | DATABASE_URL=postgres://localhost/elixir_boilerplate_dev 25 | DATABASE_POOL_SIZE=20 26 | DATABASE_SSL=false 27 | 28 | # URL configuration (used by Phoenix to build URLs from routes) 29 | # Other features also extracts values from this URL: 30 | # - Redirect to canonical host 31 | # - Force SSL requests 32 | CANONICAL_URL=http://localhost:4000 33 | 34 | # Telemtry UI configuration 35 | TELEMETRY_UI_SHARE_KEY= # Generate 15 random characters with `mix phx.gen.secret | cut -c 1-15` 36 | 37 | # Static URL configuration (used by Phoenix to generate static file URLs, eg. 38 | # CSS and JavaScript). We often use these variables to configure a CDN that 39 | # will cache static files once they have been served by the Phoenix 40 | # application. 41 | # STATIC_URL= 42 | 43 | # Basic Authentication 44 | # BASIC_AUTH_USERNAME= 45 | # BASIC_AUTH_PASSWORD= 46 | 47 | # New Relic configuration 48 | # NEW_RELIC_APP_NAME= 49 | # NEW_RELIC_LICENSE_KEY= 50 | 51 | # Sentry configuration 52 | # Sentry requires an environment name (but not a DSN) 53 | # SENTRY_DSN= 54 | SENTRY_ENVIRONMENT_NAME=local 55 | 56 | # Absinthe configuration 57 | GRAPHQL_ENABLE_INTROSPECTION=true 58 | GRAPHQL_ENABLE_FIELD_INSPECTION=true 59 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Language: en" 4 | 5 | msgid "can't be blank" 6 | msgstr "can’t be blank" 7 | 8 | msgid "has already been taken" 9 | msgstr "has already been taken" 10 | 11 | msgid "is invalid" 12 | msgstr "is invalid" 13 | 14 | msgid "must be accepted" 15 | msgstr "must be accepted" 16 | 17 | msgid "has invalid format" 18 | msgstr "has invalid format" 19 | 20 | msgid "has an invalid entry" 21 | msgstr "has an invalid entry" 22 | 23 | msgid "is reserved" 24 | msgstr "is reserved" 25 | 26 | msgid "does not match confirmation" 27 | msgstr "does not match confirmation" 28 | 29 | msgid "is still associated with this entry" 30 | msgstr "is still associated with this entry" 31 | 32 | msgid "are still associated with this entry" 33 | msgstr "are still associated with this entry" 34 | 35 | msgid "should be %{count} character(s)" 36 | msgid_plural "should be %{count} character(s)" 37 | msgstr[0] "should be 1 character" 38 | msgstr[1] "should be %{count} characters" 39 | 40 | msgid "should have %{count} item(s)" 41 | msgid_plural "should have %{count} item(s)" 42 | msgstr[0] "should have 1 item" 43 | msgstr[1] "should have %{count} items" 44 | 45 | msgid "should be at least %{count} character(s)" 46 | msgid_plural "should be at least %{count} character(s)" 47 | msgstr[0] "should be at least 1 character" 48 | msgstr[1] "should be at least %{count} characters" 49 | 50 | msgid "should have at least %{count} item(s)" 51 | msgid_plural "should have at least %{count} item(s)" 52 | msgstr[0] "should have at least 1 item" 53 | msgstr[1] "should have at least %{count} items" 54 | 55 | msgid "should be at most %{count} character(s)" 56 | msgid_plural "should be at most %{count} character(s)" 57 | msgstr[0] "should be at most 1 character" 58 | msgstr[1] "should be at most %{count} characters" 59 | 60 | msgid "should have at most %{count} item(s)" 61 | msgid_plural "should have at most %{count} item(s)" 62 | msgstr[0] "should have at most 1 item" 63 | msgstr[1] "should have at most %{count} items" 64 | 65 | msgid "must be less than %{number}" 66 | msgstr "must be less than %{number}" 67 | 68 | msgid "must be greater than %{number}" 69 | msgstr "must be greater than %{number}" 70 | 71 | msgid "must be less than or equal to %{number}" 72 | msgstr "must be less than or equal to %{number}" 73 | 74 | msgid "must be greater than or equal to %{number}" 75 | msgstr "must be greater than or equal to %{number}" 76 | 77 | msgid "must be equal to %{number}" 78 | msgstr "must be equal to %{number}" 79 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/errors/errors.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.Errors do 2 | @moduledoc false 3 | import Phoenix.Template, only: [embed_templates: 1] 4 | 5 | alias Ecto.Changeset 6 | 7 | embed_templates("templates/*") 8 | 9 | @doc """ 10 | Generates a human-readable block containing all errors in a changeset. Errors 11 | are then localized using translations in the `ecto` domain. 12 | 13 | For example, you could have an `errors.po` file in the french locale: 14 | 15 | ``` 16 | msgid "" 17 | msgstr "" 18 | "Language: fr" 19 | 20 | msgid "can't be blank" 21 | msgstr "ne peut être vide" 22 | ``` 23 | """ 24 | def changeset_to_error_messages(changeset) do 25 | changeset 26 | |> Changeset.traverse_errors(&translate_error/1) 27 | |> convert_errors_to_html(changeset.data.__struct__) 28 | end 29 | 30 | defp translate_error({message, options}) do 31 | if options[:count] do 32 | Gettext.dngettext(ElixirBoilerplate.Gettext, "errors", message, message, options[:count], options) 33 | else 34 | Gettext.dgettext(ElixirBoilerplate.Gettext, "errors", message, options) 35 | end 36 | end 37 | 38 | defp convert_errors_to_html(errors, schema) do 39 | errors = Enum.reduce(errors, [], &convert_error_field(&1, &2, schema)) 40 | 41 | error_messages(%{errors: errors}) 42 | end 43 | 44 | defp convert_error_field({field, errors}, memo, schema) when is_list(errors) do 45 | memo ++ Enum.flat_map(errors, &convert_error_subfield(&1, field, [], schema)) 46 | end 47 | 48 | defp convert_error_field({field, errors}, memo, schema) when is_map(errors) do 49 | memo ++ Enum.flat_map(Map.keys(errors), &convert_error_subfield(&1, field, errors[&1], schema)) 50 | end 51 | 52 | defp convert_error_subfield(message, field, _, _schema) when is_binary(message) do 53 | # NOTE `schema` is available here if we want to use something like 54 | # `schema.humanize_field(field)` to be able to display `"Email address is 55 | # invalid"` instead of `email is invalid"`. 56 | ["#{field} #{message}"] 57 | end 58 | 59 | defp convert_error_subfield(message, field, memo, schema) when is_map(message) do 60 | Enum.reduce(message, memo, fn {subfield, errors}, memo -> 61 | memo ++ convert_error_field({"#{field}.#{subfield}", errors}, memo, schema) 62 | end) 63 | end 64 | 65 | defp convert_error_subfield(subfield, field, errors, schema) do 66 | field = "#{field}.#{subfield}" 67 | convert_error_field({field, errors}, [], schema) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/elixir_boilerplate_web/errors_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.ErrorsTest do 2 | use ElixirBoilerplate.DataCase, async: true 3 | 4 | alias ElixirBoilerplateWeb.Errors 5 | 6 | defmodule UserRole do 7 | @moduledoc false 8 | use Ecto.Schema 9 | 10 | import Ecto.Changeset 11 | 12 | embedded_schema do 13 | field(:type, :string) 14 | 15 | timestamps() 16 | end 17 | 18 | def changeset(%__MODULE__{} = user_role, params) do 19 | user_role 20 | |> cast(params, [:type]) 21 | |> validate_required([:type]) 22 | |> validate_inclusion(:type, ~w(admin moderator member)) 23 | end 24 | end 25 | 26 | defmodule User do 27 | @moduledoc false 28 | use Ecto.Schema 29 | 30 | import Ecto.Changeset 31 | 32 | schema "users" do 33 | field(:username, :string) 34 | field(:email, :string) 35 | field(:nicknames, {:array, :string}) 36 | 37 | embeds_one(:single_role, UserRole) 38 | embeds_many(:multiple_roles, UserRole) 39 | 40 | timestamps() 41 | end 42 | 43 | def changeset(%__MODULE__{} = user, params) do 44 | user 45 | |> cast(params, [:email, :nicknames]) 46 | |> cast_embed(:single_role) 47 | |> cast_embed(:multiple_roles) 48 | |> validate_required(:username) 49 | |> validate_length(:email, is: 10) 50 | |> validate_length(:nicknames, min: 1) 51 | |> validate_format(:email, ~r/@/) 52 | end 53 | end 54 | 55 | test "error_messages/1 without errors should return an empty string" do 56 | html = 57 | %User{} 58 | |> change() 59 | |> changeset_to_error_messages() 60 | 61 | assert html == "" 62 | end 63 | 64 | test "error_messages/1 should render error messages on changeset" do 65 | html = 66 | %User{} 67 | |> User.changeset(%{"email" => "foo", "nicknames" => [], "single_role" => %{"type" => "bar"}, "multiple_roles" => [%{"type" => ""}]}) 68 | |> changeset_to_error_messages() 69 | 70 | assert html =~ "
  • email has invalid format[validation=:format]
  • " 71 | assert html =~ "
  • email should be %{count} character(s)[count=10,kind=:is,type=:string,validation=:length]
  • " 72 | assert html =~ "
  • multiple_roles.type can't be blank[validation=:required]
  • " 73 | assert html =~ "
  • nicknames should have at least %{count} item(s)[count=1,kind=:min,type=:list,validation=:length]
  • " 74 | assert html =~ "
  • single_role.type is invalid[enum=admin,moderator,member,validation=:inclusion]
  • " 75 | end 76 | 77 | defp changeset_to_error_messages(changeset) do 78 | changeset 79 | |> Errors.changeset_to_error_messages() 80 | |> Phoenix.HTML.Safe.to_iodata() 81 | |> IO.iodata_to_binary() 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODEJS_VERSION=22-bookworm-slim 2 | ARG ELIXIR_VERSION=1.18.1 3 | ARG OTP_VERSION=27.2 4 | ARG DEBIAN_VERSION=bookworm-20241223-slim 5 | 6 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 7 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 8 | 9 | # ----------------------------------------------- 10 | # Stage: npm dependencies 11 | # ----------------------------------------------- 12 | FROM node:${NODEJS_VERSION} AS npm-builder 13 | 14 | # Install Debian dependencies 15 | RUN apt-get update -y && \ 16 | apt-get install -y build-essential git && \ 17 | apt-get clean && \ 18 | rm -f /var/lib/apt/lists/*_* 19 | 20 | WORKDIR /app 21 | 22 | # Install npm dependencies 23 | COPY assets assets 24 | RUN npm ci --prefix assets 25 | 26 | # ----------------------------------------------- 27 | # Stage: hex dependencies + OTP release 28 | # ----------------------------------------------- 29 | FROM ${BUILDER_IMAGE} AS hex-builder 30 | 31 | # install build dependencies 32 | RUN apt-get update -y && \ 33 | apt-get install -y build-essential git && \ 34 | apt-get clean && \ 35 | rm -f /var/lib/apt/lists/*_* 36 | 37 | # prepare build dir 38 | WORKDIR /app 39 | 40 | ENV MIX_ENV=prod 41 | ENV ERL_FLAGS="+JPperf true" 42 | 43 | # install hex + rebar 44 | RUN mix local.hex --force && \ 45 | mix local.rebar --force 46 | 47 | # set build ENV 48 | ENV MIX_ENV="prod" 49 | 50 | # install mix dependencies 51 | COPY mix.exs mix.lock ./ 52 | RUN mix deps.get --only $MIX_ENV 53 | 54 | # copy compile-time config files before we compile dependencies 55 | # to ensure any relevant config change will trigger the dependencies 56 | # to be re-compiled. 57 | RUN mkdir config 58 | COPY config/config.exs config/${MIX_ENV}.exs config/ 59 | RUN mix deps.compile 60 | 61 | # install Esbuild so it is cached 62 | RUN mix esbuild.install --if-missing 63 | 64 | COPY lib lib 65 | COPY --from=npm-builder /app/assets assets 66 | COPY priv priv 67 | 68 | # Compile assets 69 | RUN mix assets.deploy 70 | 71 | # Compile the release 72 | RUN mix compile 73 | 74 | # Changes to config/runtime.exs don't require recompiling the code 75 | COPY config/runtime.exs config/ 76 | 77 | COPY rel rel 78 | RUN mix release 79 | 80 | # ----------------------------------------------- 81 | # Stage: Bundle release in a docker image 82 | # ----------------------------------------------- 83 | FROM ${RUNNER_IMAGE} 84 | 85 | RUN apt-get update -y && \ 86 | apt-get install -y curl jq libstdc++6 openssl libncurses5 locales && \ 87 | apt-get clean && \ 88 | rm -f /var/lib/apt/lists/*_* 89 | 90 | # Set the locale 91 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 92 | 93 | WORKDIR "/app" 94 | RUN chown nobody /app 95 | 96 | # set runner ENV 97 | ENV LANG=en_US.UTF-8 98 | ENV LANGUAGE=en_US:en 99 | ENV LC_ALL=en_US.UTF-8 100 | ENV MIX_ENV="prod" 101 | 102 | # Only copy the final release from the build stage 103 | COPY --from=hex-builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/elixir_boilerplate ./ 104 | 105 | USER nobody 106 | 107 | CMD ["sh", "-c", "/app/bin/migrate && /app/bin/server"] 108 | -------------------------------------------------------------------------------- /docs/module-naming.fr.md: -------------------------------------------------------------------------------- 1 | _Prendre connaissance de https://hexdocs.pm/phoenix/contexts.html avant de lire ce document. ;)_ 2 | 3 | **Grouping:** 4 | 5 | - `MyAppWeb` - Regroupe ce qui touche au "web" 6 | - `MyAppGraphQL` - Regroupe ce qui touche à GraphQL 7 | 8 | **Contextes:** 9 | 10 | - `MyApp.Accounts` - Contexte exposant ce qui est lié aux comptes de l’app. 11 | - `MyApp.LinkSharing` - Context exposant la fonctionnalité de share de link utilisé dans 3 autres contextes. 12 | 13 | **Modules partagées:** 14 | 15 | - `MyApp.Repo` - Module partagé exposant les fonctionnalités pour parler à la DB 16 | - `MyApp.Audits` - Module partagé exposant la fonctionnalité d’audit d’entité 17 | - `MyApp.Permissions` - Module partagé exposant la fonctionnalité de permissions entre resources 18 | 19 | ## Regroupements versus Contextes 20 | 21 | Les regroupements utilises les APIs des contextes dans leurs "leafs" (controllers et resolvers). 22 | 23 | ## Les grandes lignes des contextes 24 | 25 | ### Les contextes ne se connaissent pas 26 | 27 | `Accounts` n’appelle pas `LinkSharing` après la création d’un user. C’est le controller qui s’occupe des side-effects d’un context. 28 | 29 | Comme la façon que `Accounts` utilise `Repo`, un context peut utilisé un autre context partagé pour avoir accès à une fonctionnalité "global" et "abstraite" du système. 30 | "Global" et "abstraite" veut dire que ce n’est pas une fonctionnalité de l’app. `LinkSharing` est une fonctionnalité, `Permissions` non. 31 | 32 | > Mais `Audits` devient une fonctionnalité si on a un listing d’historiques dans l’app. 33 | 34 | Dans ce cas, un context d’historique pourrait être rendu disponible (qui utiliserait le module partagé `Audits`). Ce context `History` expose un API qui reflèterait la fonctionnalité et qui ne serait pas mêlé au module `Audits` pour qui le role de "prendre n’importe quel action et en garder une trace" ne serait pas touché. 35 | 36 | > If you find yourself in similar situations where you feel your use case is requiring you to create circular dependencies across contexts, it’s a sign you need a new context in the system to handle these application requirements. In our case, what we really want is an interface that handles all requirements when a user is created or registers in our application. To handle this, we could create a UserRegistration context, which calls into both the Accounts and CMS APIs to create a user, then associate a CMS author. Not only would this allow our Accounts to remain as isolated as possible, it gives us a clear, obvious API to handle UserRegistration needs in the system. 37 | 38 | ### Un module de 1000 lignes, c’est oui! 39 | 40 | Un context est l’API public d’une des core business de l’app. Le module pour gérer les Enumerable dans Elixir: https://imgur.com/a/IUAyOZM 41 | 42 | L’important est de ne pas éparpiller les fonctionnalités. Imaginez le language avec un module `ListSorter`, `ListTaker`, `ArraySlicer`, etc. 43 | C’est pareille pour une application. Le context `Accounts` englobe ce qui est possible de faire `create_user`, `update_user`, `block_user`. 44 | 45 | _Un module de 1000 lignes, avec @moduledoc, @doc, @spec, c’est oui!_ 46 | 47 | ### Modules privés dans les contextes 48 | 49 | Pour ne pas arriver à un module avec trop de dépendances "direct" (mettont 6 alias, 4 import, 3 use), on sépare les fonctionnalités en groupes: 50 | 51 | - **MyApp.Accounts.UserPersistence** Va gérer les intéractions avec le `Repo` et importer `Ecto.Query` 52 | - **MyApp.Accounts.UserSearch** Va gérer l’indexation et la query à Elasticsearch en important le module `Elasticsearch.Query` 53 | 54 | Ces modules sont utilisés pour alléger `MyApp.Accounts` et ne devrait en aucun cas _leaker_ dans un controller ou pire, un autre context. 55 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplateWeb.Endpoint do 2 | use Sentry.PlugCapture 3 | use Phoenix.Endpoint, otp_app: :elixir_boilerplate 4 | 5 | alias Plug.Conn 6 | 7 | @plug_ssl Plug.SSL.init(rewrite_on: [:x_forwarded_proto], subdomains: true) 8 | 9 | socket("/socket", ElixirBoilerplateWeb.Socket) 10 | socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: {ElixirBoilerplateWeb.Session, :config, []}]]) 11 | 12 | plug(ElixirBoilerplateWeb.Plugs.Security) 13 | plug(:ping) 14 | plug(:canonical_host) 15 | plug(:force_ssl) 16 | plug(:cors) 17 | plug(:basic_auth) 18 | 19 | # Serve at "/" the static files from "priv/static" directory. 20 | # 21 | # You should set gzip to true if you are running phoenix.digest 22 | # when deploying your static files in production. 23 | plug(Plug.Static, 24 | at: "/", 25 | from: :elixir_boilerplate, 26 | gzip: true, 27 | only: ~w(assets fonts images favicon.svg robots.txt) 28 | ) 29 | 30 | # Code reloading can be explicitly enabled under the 31 | # :code_reloader configuration of your endpoint. 32 | if code_reloading? do 33 | socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket) 34 | 35 | plug(Phoenix.LiveReloader) 36 | plug(Phoenix.CodeReloader) 37 | plug(Phoenix.Ecto.CheckRepoStatus, otp_app: :elixir_boilerplate) 38 | end 39 | 40 | plug(Plug.RequestId) 41 | plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint]) 42 | 43 | plug( 44 | Plug.Parsers, 45 | parsers: [:urlencoded, :multipart, :json], 46 | pass: ["*/*"], 47 | json_decoder: Phoenix.json_library() 48 | ) 49 | 50 | plug(Sentry.PlugContext, 51 | body_scrubber: {ElixirBoilerplate.Errors.Sentry, :scrub_params}, 52 | remote_address_reader: {ElixirBoilerplate.Errors.Sentry, :scrubbed_remote_address} 53 | ) 54 | 55 | plug(Plug.MethodOverride) 56 | plug(Plug.Head) 57 | 58 | plug(ElixirBoilerplateHealth.Router) 59 | plug(ElixirBoilerplateGraphQL.Router) 60 | plug(:halt_if_sent) 61 | plug(ElixirBoilerplateWeb.Router) 62 | 63 | # sobelow_skip ["XSS.SendResp"] 64 | defp ping(%{request_path: "/ping"} = conn, _opts) do 65 | version = Application.get_env(:elixir_boilerplate, :version) 66 | response = Jason.encode!(%{status: "ok", version: version}) 67 | 68 | conn 69 | |> Conn.put_resp_header("content-type", "application/json") 70 | |> Conn.send_resp(200, response) 71 | |> Conn.halt() 72 | end 73 | 74 | defp ping(conn, _opts), do: conn 75 | 76 | defp canonical_host(%{request_path: "/health"} = conn, _opts), do: conn 77 | 78 | defp canonical_host(conn, _opts) do 79 | opts = PlugCanonicalHost.init(canonical_host: Application.get_env(:elixir_boilerplate, :canonical_host)) 80 | 81 | PlugCanonicalHost.call(conn, opts) 82 | end 83 | 84 | defp force_ssl(%{request_path: "/health"} = conn, _opts), do: conn 85 | 86 | defp force_ssl(conn, _opts) do 87 | if Application.get_env(:elixir_boilerplate, :force_ssl) do 88 | Plug.SSL.call(conn, @plug_ssl) 89 | else 90 | conn 91 | end 92 | end 93 | 94 | defp cors(conn, _opts) do 95 | opts = Corsica.init(Application.get_env(:elixir_boilerplate, Corsica)) 96 | 97 | Corsica.call(conn, opts) 98 | end 99 | 100 | defp basic_auth(conn, _opts) do 101 | basic_auth_config = Application.get_env(:elixir_boilerplate, :basic_auth) 102 | 103 | if basic_auth_config[:username] do 104 | Plug.BasicAuth.basic_auth(conn, basic_auth_config) 105 | else 106 | conn 107 | end 108 | end 109 | 110 | # Splitting routers in separate modules has a negative side effect: 111 | # Phoenix.Router does not check the Plug.Conn state and tries to match the 112 | # route even if it was already handled/sent by another router. 113 | defp halt_if_sent(%{state: :sent, halted: false} = conn, _opts), do: halt(conn) 114 | defp halt_if_sent(conn, _opts), do: conn 115 | end 116 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 6 | 7 | Since it is a boilerplate project, there are technically no official (versioned) _releases_. Therefore, the `main` branch should always be stable and usable. 8 | 9 | ## 2024-02-19 10 | 11 | - Add AbsintheSecurity (#500) 12 | 13 | ## 2023-10-11 14 | 15 | - Add TelemetryUI (#414) 16 | 17 | ## 2023-01-27 18 | 19 | - Add security HTTP headers for the whole endpoint (#241) 20 | 21 | ## 2021-03-23 22 | 23 | - Generate and expose JS source maps by default (#142) 24 | - Use gzip to serve assets (#143) 25 | 26 | ## 2021-03-10 27 | 28 | - Add `Credo.Check.Readability.StrictModuleLayout` readability check 29 | 30 | ## 2021-03-03 31 | 32 | - Upgrade to Erlang `23.2` 33 | - Upgrade mix dependencies 34 | - Upgrage NPM dependencies to the latest compatible versions of Webpack 4 35 | - Change configuration files to adopt the \*.config.js standard 36 | 37 | ## 2020-11-25 38 | 39 | - Upgrage to Elixir `1.11` and NodeJS `14.15` 40 | 41 | ## 2020-11-13 42 | 43 | - Revert the removal of `PORT` environment variable (#133) 44 | 45 | ## 2020-11-12 46 | 47 | Simplification of the Router URLs configuration (#132) 48 | 49 | Router's Endpoint config now requires only a CANONICAL_URL and a STATIC_URL from which it extrapolates the different URI components such as `scheme`, `host` and `port`. 50 | 51 | Environment variables changes: 52 | 53 | _Added_ 54 | 55 | - `CANONICAL_URL=` 56 | - `STATIC_URL=` 57 | 58 | _Removed_ 59 | 60 | - `PORT=` 61 | - `FORCE_SSL=` 62 | - `STATIC_URL_SCHEME=` 63 | - `STATIC_URL_HOST=` 64 | - `STATIC_URL_PORT=` 65 | 66 | ## 2020-11-04 67 | 68 | - Move `Plug.SSL` plug initialization to endpoint module attribute (#130) 69 | 70 | ## 2020-10-08 71 | 72 | - Upgrage to Erlang `23.1.1` and Alpine `1.12.0` 73 | - Upgrade to Phoenix `1.5` 74 | - Upgrade to Ecto `3.5` 75 | - Upgrade to Absinthe `1.5` 76 | 77 | ### New Relic instrumentation for Phoenix 78 | 79 | The `instrumenters` configuration was deprecated from `Phoenix.Endpoint`, and there is no update in [`new_relix_phoenix`](https://hex.pm/packages/new_relic_phoenix) yet to reflect this change! The instrumenter might not work properly… 80 | 81 | > [warn] :instrumenters configuration for ElixirBoilerplateWeb.Endpoint is deprecated and has no effect 82 | 83 | ## 2020-06-17 84 | 85 | - Add MixAudit vulnerability security scanning (#114) 86 | 87 | ## 2020-05-27 88 | 89 | - Do not provide `static_url` configuration to Phoenix endpoint if `STATIC_URL_HOST` isn’t present (#110) 90 | 91 | ## 2020-05-15 92 | 93 | - Do not raise “missing `_test` suffix” error when DATABASE_URL is not present 94 | - Refactor router split to avoid “You have instrumented twice in the same plug” New Relic warning (#108) 95 | 96 | ## 2020-03-26 97 | 98 | ### Fixed 99 | 100 | - `make` targets using `npx` now work properly since we now change the current working directory to `assets` before running them 101 | - The `boilerplate-setup.sh` script now supports PascalCase name with consecutive uppercase letters (eg. `FooBarBBQ` → `foo_bar_bbq`) 102 | - The `boilerplate-setup.sh` script now takes into account deeper hierarchy files and the Github Action CI workflow file 103 | - The `BOILERPLATE_README.fr.md` and `BOILERPLATE_README.md` now list the correct dependencies 104 | 105 | ### Added 106 | 107 | - Added a local database URL check for the test configuration which prevents tests from being run on an external database 108 | 109 | ## 2020-03-18 110 | 111 | ### Fixed 112 | 113 | - Makefile (`Makefile`) output of the different targets when using numbers 114 | 115 | ## 2020-01-22 116 | 117 | ### Updated 118 | 119 | - Improve Docker-related environment variables in Makefile (#86) 120 | 121 | ## 2019-12-19 122 | 123 | ### Added 124 | 125 | - Improved healthcheck setup with `plug_checkup` (#84) 126 | 127 | ### Updated 128 | 129 | - Upgrade from `alpine:3.9` to `alpine:3.10` as base Docker image 130 | 131 | ## 2019-10-18 132 | 133 | ### Added 134 | 135 | - Project changelog (`CHANGELOG.md`) 136 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :elixir_boilerplate, 7 | version: "0.0.1", 8 | erlang: "~> 27.0", 9 | elixir: "~> 1.18", 10 | elixirc_paths: elixirc_paths(Mix.env()), 11 | test_paths: ["test"], 12 | test_pattern: "**/*_test.exs", 13 | test_coverage: [tool: ExCoveralls], 14 | preferred_cli_env: [coveralls: :test, "coveralls.detail": :test, "coveralls.post": :test, "coveralls.html": :test], 15 | start_permanent: Mix.env() == :prod, 16 | listeners: [Phoenix.CodeReloader], 17 | aliases: aliases(), 18 | deps: deps(), 19 | dialyzer: dialyzer(), 20 | releases: releases() 21 | ] 22 | end 23 | 24 | def application do 25 | [ 26 | mod: {ElixirBoilerplate.Application, []}, 27 | extra_applications: extra_applications(Mix.env()) ++ [:logger, :runtime_tools] 28 | ] 29 | end 30 | 31 | defp elixirc_paths(:test), do: ["lib", "test/support"] 32 | defp elixirc_paths(_), do: ["lib"] 33 | 34 | defp extra_applications(:dev), do: [:observer, :wx] 35 | defp extra_applications(_), do: [] 36 | 37 | defp aliases do 38 | [ 39 | "assets.deploy": [ 40 | "esbuild default --minify", 41 | "phx.digest" 42 | ], 43 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 44 | "ecto.reset": ["ecto.drop", "ecto.setup"], 45 | test: ["ecto.create --quiet", "ecto.migrate", "test"] 46 | ] 47 | end 48 | 49 | defp deps do 50 | [ 51 | # Assets bundling 52 | {:esbuild, "~> 0.7", runtime: Mix.env() == :dev}, 53 | 54 | # HTTP Client 55 | {:hackney, "~> 1.18"}, 56 | 57 | # HTTP server 58 | {:plug_cowboy, "~> 2.6"}, 59 | {:plug_canonical_host, "~> 2.0"}, 60 | {:corsica, "~> 2.1"}, 61 | 62 | # Phoenix 63 | {:phoenix, "~> 1.7"}, 64 | {:phoenix_html, "~> 3.3"}, 65 | {:phoenix_live_view, "~> 1.0"}, 66 | {:phoenix_ecto, "~> 4.4"}, 67 | {:phoenix_live_reload, "~> 1.4", only: :dev}, 68 | {:jason, "~> 1.4"}, 69 | 70 | # GraphQL 71 | {:absinthe, "~> 1.7"}, 72 | {:absinthe_security, "~> 0.1"}, 73 | {:absinthe_plug, "~> 1.5"}, 74 | {:dataloader, "~> 2.0"}, 75 | {:absinthe_error_payload, "~> 1.1"}, 76 | 77 | # Database 78 | {:ecto_sql, "~> 3.10"}, 79 | {:postgrex, "~> 0.17"}, 80 | 81 | # Database check 82 | {:excellent_migrations, "~> 0.1", only: [:dev, :test], runtime: false}, 83 | 84 | # Translations 85 | {:gettext, "~> 1.0", override: true}, 86 | 87 | # Errors 88 | {:sentry, "~> 10.10"}, 89 | 90 | # Monitoring 91 | {:new_relic_agent, "~> 1.27"}, 92 | {:new_relic_absinthe, "~> 0.0"}, 93 | 94 | # Telemetry 95 | {:telemetry_ui, "~> 5.0"}, 96 | 97 | # Linting 98 | {:credo, "~> 1.7", only: [:dev, :test], override: true}, 99 | {:credo_envvar, "~> 0.1", only: [:dev, :test], runtime: false}, 100 | {:credo_naming, "~> 2.0", only: [:dev, :test], runtime: false}, 101 | {:styler, "~> 1.0", only: [:dev, :test], runtime: false}, 102 | 103 | # Security check 104 | {:sobelow, "~> 0.12", only: [:dev, :test], runtime: true}, 105 | {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, 106 | 107 | # Health 108 | {:plug_checkup, "~> 0.6"}, 109 | 110 | # Test factories 111 | {:ex_machina, "~> 2.7", only: :test}, 112 | {:faker, "~> 0.17", only: :test}, 113 | 114 | # Test coverage 115 | {:excoveralls, "~> 0.16", only: :test}, 116 | 117 | # Dialyzer 118 | {:dialyxir, "~> 1.3", only: [:dev, :test], runtime: false} 119 | ] 120 | end 121 | 122 | defp dialyzer do 123 | [ 124 | plt_file: {:no_warn, "priv/plts/elixir_boilerplate.plt"}, 125 | plt_add_apps: [:mix, :ex_unit] 126 | ] 127 | end 128 | 129 | defp releases do 130 | [ 131 | elixir_boilerplate: [ 132 | version: {:from_app, :elixir_boilerplate}, 133 | applications: [elixir_boilerplate: :permanent], 134 | include_executables_for: [:unix], 135 | steps: [:assemble, :tar] 136 | ] 137 | ] 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Build configuration 2 | # ------------------- 3 | 4 | APP_NAME := $(shell grep -Eo 'app: :\w*' mix.exs | cut -d ':' -f 3) 5 | APP_VERSION := $(shell grep -Eo 'version: "[0-9\.]*"' mix.exs | cut -d '"' -f 2) 6 | GIT_REVISION := $(shell git rev-parse HEAD) 7 | DOCKER_IMAGE_TAG ?= $(APP_VERSION) 8 | DOCKER_REGISTRY ?= 9 | DOCKER_LOCAL_IMAGE:= $(APP_NAME):$(DOCKER_IMAGE_TAG) 10 | DOCKER_REMOTE_IMAGE:= $(DOCKER_REGISTRY)/$(DOCKER_LOCAL_IMAGE) 11 | 12 | # Linter and formatter configuration 13 | # ---------------------------------- 14 | 15 | PRETTIER_FILES_PATTERN = '*.config.js' '{js,css,scripts}/**/*.{js,graphql,scss,css}' '../*.md' '../*/*.md' 16 | STYLES_PATTERN = 'css' 17 | 18 | # Introspection targets 19 | # --------------------- 20 | 21 | .PHONY: help 22 | help: header targets 23 | 24 | .PHONY: header 25 | header: 26 | @echo "\033[34mEnvironment\033[0m" 27 | @echo "\033[34m---------------------------------------------------------------\033[0m" 28 | @printf "\033[33m%-23s\033[0m" "APP_NAME" 29 | @printf "\033[35m%s\033[0m" $(APP_NAME) 30 | @echo "" 31 | @printf "\033[33m%-23s\033[0m" "APP_VERSION" 32 | @printf "\033[35m%s\033[0m" $(APP_VERSION) 33 | @echo "" 34 | @printf "\033[33m%-23s\033[0m" "GIT_REVISION" 35 | @printf "\033[35m%s\033[0m" $(GIT_REVISION) 36 | @echo "" 37 | @printf "\033[33m%-23s\033[0m" "DOCKER_IMAGE_TAG" 38 | @printf "\033[35m%s\033[0m" $(DOCKER_IMAGE_TAG) 39 | @echo "" 40 | @printf "\033[33m%-23s\033[0m" "DOCKER_REGISTRY" 41 | @printf "\033[35m%s\033[0m" $(DOCKER_REGISTRY) 42 | @echo "" 43 | @printf "\033[33m%-23s\033[0m" "DOCKER_LOCAL_IMAGE" 44 | @printf "\033[35m%s\033[0m" $(DOCKER_LOCAL_IMAGE) 45 | @echo "" 46 | @printf "\033[33m%-23s\033[0m" "DOCKER_REMOTE_IMAGE" 47 | @printf "\033[35m%s\033[0m" $(DOCKER_REMOTE_IMAGE) 48 | @echo "\n" 49 | 50 | .PHONY: targets 51 | targets: 52 | @echo "\033[34mTargets\033[0m" 53 | @echo "\033[34m---------------------------------------------------------------\033[0m" 54 | @perl -nle'print $& if m{^[a-zA-Z_-\d]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}' 55 | 56 | # Build targets 57 | # ------------- 58 | 59 | .PHONY: prepare 60 | prepare: 61 | mix deps.get 62 | npm ci --prefix assets 63 | 64 | .PHONY: build 65 | build: ## Build a Docker image for the OTP release 66 | docker build --rm --tag $(DOCKER_LOCAL_IMAGE) . 67 | 68 | .PHONY: push 69 | push: ## Push the Docker image to the registry 70 | docker tag $(DOCKER_LOCAL_IMAGE) $(DOCKER_REMOTE_IMAGE) 71 | docker push $(DOCKER_REMOTE_IMAGE) 72 | 73 | # Development targets 74 | # ------------------- 75 | 76 | .PHONY: run 77 | run: ## Run the server in an IEx shell 78 | iex -S mix phx.server 79 | 80 | .PHONY: dependencies 81 | dependencies: ## Install hex and npm dependencies 82 | mix deps.get 83 | npm install --prefix assets 84 | 85 | .PHONY: sync-translations 86 | sync-translations: ## Synchronize translations with Accent 87 | npx accent sync --add-translations --order-by=key-asc 88 | 89 | .PHONY: test 90 | test: ## Run the test suite 91 | mix test --warnings-as-errors 92 | 93 | # Check, lint and format targets 94 | # ------------------------------ 95 | 96 | .PHONY: check 97 | check: check-format check-unused-dependencies check-dependencies-security check-code-security check-static-typing check-code-coverage ## Run various checks on source files 98 | 99 | .PHONY: check-code-coverage 100 | check-code-coverage: 101 | mix coveralls 102 | 103 | .PHONY: check-dependencies-security 104 | check-dependencies-security: 105 | mix deps.audit 106 | 107 | .PHONY: check-code-security 108 | check-code-security: 109 | mix sobelow --config 110 | 111 | .PHONY: check-format 112 | check-format: 113 | mix format --check-formatted 114 | cd assets && npx prettier --check $(PRETTIER_FILES_PATTERN) 115 | 116 | .PHONY: check-unused-dependencies 117 | check-unused-dependencies: 118 | mix deps.unlock --check-unused 119 | 120 | .PHONY: check-static-typing 121 | check-static-typing: 122 | mix dialyzer 123 | 124 | .PHONY: format 125 | format: ## Format source files 126 | mix format 127 | cd assets && npx prettier --write $(PRETTIER_FILES_PATTERN) 128 | 129 | .PHONY: lint 130 | lint: lint-elixir lint-scripts ## Lint source files 131 | 132 | .PHONY: lint-elixir 133 | lint-elixir: 134 | mix compile --warnings-as-errors --force 135 | mix credo --strict 136 | 137 | .PHONY: lint-scripts 138 | lint-scripts: 139 | cd assets && npx eslint . 140 | -------------------------------------------------------------------------------- /boilerplate-setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # ----------------------------------------------------------------------------- 4 | # Configuration 5 | # ----------------------------------------------------------------------------- 6 | 7 | pascalCaseBefore="ElixirBoilerplate" 8 | snakeCaseBefore="elixir_boilerplate" 9 | kebabCaseBefore="elixir-boilerplate" 10 | 11 | # The identifiers above will be replaced in the content of the files found below 12 | content=$(find . -type f \( \ 13 | -name "*.ex" -or \ 14 | -name "*.exs" -or \ 15 | -name "*.ees" -or \ 16 | -name "*.sh" -or \ 17 | -name "*.json" -or \ 18 | -name "*.js" -or \ 19 | -name "*.yml" -or \ 20 | -name "*.yaml" -or \ 21 | -name "*.md" -or \ 22 | -name ".env.*" -or \ 23 | -name "Dockerfile" -or \ 24 | -name "Makefile" -or \ 25 | -path "./rel/overlays/bin/*" \ 26 | \) -and \ 27 | ! -path "./boilerplate-setup.sh" -and \ 28 | ! -path "./deps/*" -and \ 29 | ! -path "./_build/*" -and \ 30 | ! -path "./assets/node_modules/*" \ 31 | ) 32 | 33 | # The identifiers above will be replaced in the path of the files and directories found here 34 | paths=$(find . -maxdepth 2 \( \ 35 | -path "./lib/${snakeCaseBefore}" -or \ 36 | -path "./lib/${snakeCaseBefore}_*" -or \ 37 | -path "./test/${snakeCaseBefore}" -or \ 38 | -path "./test/${snakeCaseBefore}_*" \ 39 | \)) 40 | 41 | files=$(find . \( \ 42 | -path "./lib/${snakeCaseBefore}.*" -or \ 43 | -path "./lib/${snakeCaseBefore}*/${snakeCaseBefore}*" \ 44 | \)) 45 | 46 | # ----------------------------------------------------------------------------- 47 | # Validation 48 | # ----------------------------------------------------------------------------- 49 | 50 | if [[ -z $(echo "$1" | grep "^[A-Z]") ]] ; then 51 | echo 'You must specify your project name in PascalCase as first argument (eg. FooBar).' 52 | exit 0 53 | fi 54 | 55 | pascalCaseAfter=$1 56 | snakeCaseAfter=$(echo $pascalCaseAfter | /usr/bin/sed 's/\(.\)\([A-Z]\{1,\}\)/\1_\2/g' | tr '[:upper:]' '[:lower:]') 57 | kebabCaseAfter=$(echo $snakeCaseAfter | tr '_' '-') 58 | 59 | # ----------------------------------------------------------------------------- 60 | # Helper functions 61 | # ----------------------------------------------------------------------------- 62 | 63 | header() { 64 | echo "\033[0;33m▶ $1\033[0m" 65 | } 66 | 67 | success() { 68 | echo "\033[0;32m▶ $1\033[0m" 69 | } 70 | 71 | run() { 72 | echo ${@} 73 | eval "${@}" 74 | } 75 | 76 | replace_in_file() { 77 | if [[ "$OSTYPE" == "darwin"* ]]; then 78 | sed="/usr/bin/sed -i ''" 79 | else 80 | sed="/usr/bin/sed -i" 81 | fi 82 | 83 | run $sed $1 $2 84 | } 85 | 86 | # ----------------------------------------------------------------------------- 87 | # Execution 88 | # ----------------------------------------------------------------------------- 89 | 90 | header "Configuration" 91 | echo "${pascalCaseBefore} → ${pascalCaseAfter}" 92 | echo "${snakeCaseBefore} → ${snakeCaseAfter}" 93 | echo "${kebabCaseBefore} → ${kebabCaseAfter}" 94 | echo "" 95 | 96 | header "Replacing boilerplate identifiers in content" 97 | for file in $content; do 98 | replace_in_file "s/$snakeCaseBefore/$snakeCaseAfter/g" $file 99 | replace_in_file "s/$kebabCaseBefore/$kebabCaseAfter/g" $file 100 | replace_in_file "s/$pascalCaseBefore/$pascalCaseAfter/g" $file 101 | done 102 | success "Done!\n" 103 | 104 | header "Replacing boilerplate identifiers in file and directory paths" 105 | for path in $paths; do 106 | run mkdir $(echo $path | /usr/bin/sed "s/$snakeCaseBefore/$snakeCaseAfter/g" | /usr/bin/sed "s/$kebabCaseBefore/$kebabCaseAfter/g" | /usr/bin/sed "s/$pascalCaseBefore/$pascalCaseAfter/g") 107 | done 108 | for file in $files; do \ 109 | run mv $file $(echo $file | /usr/bin/sed "s/$snakeCaseBefore/$snakeCaseAfter/g" | /usr/bin/sed "s/$kebabCaseBefore/$kebabCaseAfter/g" | /usr/bin/sed "s/$pascalCaseBefore/$pascalCaseAfter/g") 110 | done 111 | for path in $paths; do 112 | run mv $path/* $(echo $path | /usr/bin/sed "s/$snakeCaseBefore/$snakeCaseAfter/g" | /usr/bin/sed "s/$kebabCaseBefore/$kebabCaseAfter/g" | /usr/bin/sed "s/$pascalCaseBefore/$pascalCaseAfter/g") 113 | run rm -rf $path 114 | done 115 | success "Done!\n" 116 | 117 | header "Importing project README.md and README.fr.md" 118 | run "rm -fr README.md && mv BOILERPLATE_README.md README.md && mv BOILERPLATE_README.fr.md README.fr.md" 119 | success "Done!\n" 120 | 121 | header "Removing boilerplate license → https://choosealicense.com" 122 | run rm -fr LICENSE.md 123 | success "Done!\n" 124 | 125 | header "Removing boilerplate changelog" 126 | run rm -fr CHANGELOG.md 127 | success "Done!\n" 128 | 129 | header "Removing boilerplate code of conduct and contribution information → https://help.github.com/articles/setting-guidelines-for-repository-contributors/" 130 | run rm -fr CODE_OF_CONDUCT.md CONTRIBUTING.md 131 | success "Done!\n" 132 | 133 | header "Removing boilerplate setup script" 134 | run rm -fr boilerplate-setup.sh 135 | success "Done!\n" 136 | -------------------------------------------------------------------------------- /BOILERPLATE_README.md: -------------------------------------------------------------------------------- 1 | # ElixirBoilerplate 2 | 3 | | Section | Description | 4 | | ----------------------------------------------------- | --------------------------------------------------------------- | 5 | | [🎯 Objectives and context](#-objectives-and-context) | Project introduction and context | 6 | | [🚧 Dependencies](#-dependencies) | Technical dependencies and how to install them | 7 | | [🏎 Kickstart](#kickstart) | Details on how to kickstart development on the project | 8 | | [🏗 Code & architecture](#-code--architecture) | Details on the application modules and technical specifications | 9 | | [🔭 Possible improvements](#-possible-improvements) | Possible code refactors, improvements and ideas | 10 | | [🚑 Troubleshooting](#-troubleshooting) | Recurring problems and proven solutions | 11 | | [🚀 Deploy](#-deploy) | Deployment details for various enviroments | 12 | 13 | ## 🎯 Objectives and context 14 | 15 | … 16 | 17 | ### Browser support 18 | 19 | | Browser | OS | Constraint | 20 | | ------- | --- | ---------- | 21 | | … | … | … | 22 | 23 | ## 🚧 Dependencies 24 | 25 | Every runtime dependencies are defined in the `.tool-versions` file. These external dependencies are also required: 26 | 27 | - PostgreSQL (`~> 12.0`) 28 | 29 | ## 🏎 Kickstart 30 | 31 | ### Environment variables 32 | 33 | All required environment variables are documented in [`.env.dev`](./.env.dev). 34 | 35 | When running `mix` or `make` commands, it is important that these variables are present in the environment. There are several ways to achieve this. Using [`nv`](https://github.com/jcouture/nv) is recommended since it works out of the box with `.env.*` files. 36 | 37 | ### Initial setup 38 | 39 | 1. Create both `.env.dev.local` and `.env.test.local` from empty values in [`.env.dev`](./.env.dev) and [`.env.test`](./.env.test) 40 | 2. Install Mix and NPM dependencies with `make dependencies` 41 | 3. Generate values for mandatory secrets in [`.env.dev`](./.env.dev) with `mix phx.gen.secret` 42 | 43 | Then, with variables from `.env.dev` and `.env.dev.local` present in the environment: 44 | 45 | 4. Create and migrate the database with `mix ecto.setup` 46 | 5. Start the Phoenix server with `make run` 47 | 48 | ### `make` commands 49 | 50 | A `Makefile` is present at the root and expose common tasks. The list of these commands is available with `make help`. 51 | 52 | ### Database 53 | 54 | To avoid running PostgreSQL locally on your machine, a `docker-compose.yml` file is included to be able start a PostgreSQL server in a Docker container with `docker-compose up postgresql`. 55 | 56 | ### Tests 57 | 58 | Tests can be ran with `make test` and test coverage can be calculated with `make check-code-coverage`. 59 | 60 | ### Linting 61 | 62 | Several linting and formatting tools can be ran to ensure coding style consistency: 63 | 64 | - `make lint-elixir` ensures Elixir code follows our guidelines and best practices 65 | - `make lint-scripts` ensures JavaScript code follows our guidelines and best practices 66 | - `make lint-styles` ensures SCSS code follows our guidelines and best practices 67 | - `make check-format` ensures all code is properly formatted 68 | - `make format` formats files using Prettier and `mix format` 69 | 70 | ### Continuous integration 71 | 72 | The `.github/workflows/ci.yaml` workflow ensures that the codebase is in good shape on each pull request and branch push. 73 | 74 | ## 🏗 Code & architecture 75 | 76 | … 77 | 78 | ## 🔭 Possible improvements 79 | 80 | | Description | Priority | Complexity | Ideas | 81 | | ----------- | -------- | ---------- | ----- | 82 | | … | … | … | … | 83 | 84 | ## 🚑 Troubleshooting 85 | 86 | ### System readiness 87 | 88 | The project exposes a `GET /ping` route that sends an HTTP `200 OK` response as soon as the server is ready to accept requests. The response also contains the project version for debugging purpose. 89 | 90 | ### System health 91 | 92 | The project exposes a `GET /health` route that serves the `ElixirBoilerplateHealth` module. This module contains checks to make sure the application and its external dependencies are healthy. 93 | 94 | | Name | Description | 95 | | ------ | ---------------------------- | 96 | | `NOOP` | This check is always healthy | 97 | 98 | ### Metrics 99 | 100 | The project exposes a [Telemetry UI](https://github.com/mirego/telemetry_ui) dashboard through the `GET /metrics` route. Metrics are configured [here](lib/elixir_boilerplate/telemetry_ui/telemetry_ui.ex). 101 | 102 | ## 🚀 Deploy 103 | 104 | ### Versions & branches 105 | 106 | Each deployment is made from a Git tag. The codebase version is managed with [`incr`](https://github.com/jcouture/incr). 107 | 108 | ### Container 109 | 110 | A Docker image running an _OTP release_ can be created with `make build`, tested with `docker-compose up application` and pushed to a registry with `make push`. 111 | -------------------------------------------------------------------------------- /BOILERPLATE_README.fr.md: -------------------------------------------------------------------------------- 1 | # ElixirBoilerplate 2 | 3 | | Section | Description | 4 | | ------------------------------------------------------- | ------------------------------------------------------------------ | 5 | | [🎯 Objectifs et contexte](#-objectifs-et-contexte) | Introduction et contexte du projet | 6 | | [🚧 Dépendances](#-dépendances) | Dépendances techniques et comment les installer | 7 | | [🏎 Départ rapide](#-départ-rapide) | Détails sur comment démarrer rapidement le développement du projet | 8 | | [🏗 Code et architecture](#-code-et-architecture) | Détails sur les composantes techniques de l’application | 9 | | [🔭 Améliorations possibles](#-améliorations-possibles) | Améliorations, idées et _refactors_ potentiels | 10 | | [🚑 Problèmes et solutions](#-problèmes-et-solutions) | Problèmes récurrents et solutions éprouvées | 11 | | [🚀 Déploiement](#-deploiement) | Détails pour le déploiement dans différents environnements | 12 | 13 | ## 🎯 Objectifs et contexte 14 | 15 | … 16 | 17 | ### Support de navigateurs 18 | 19 | | Navigateur | OS | Contrainte | 20 | | ---------- | --- | ---------- | 21 | | … | … | … | 22 | 23 | ## 🚧 Dépendances 24 | 25 | Toutes les versions des dépendances _runtime_ sont définies dans le fichier `.tool-versions`. Ces dépendances externes sont également requises : 26 | 27 | - PostgreSQL (`~> 12.0`) 28 | 29 | ## 🏎 Départ rapide 30 | 31 | ### Variables d’environnement 32 | 33 | Toutes les variables d’environnement requises sont documentées dans [`.env.dev`](./.env.dev). 34 | 35 | Ces variables doivent être présentes dans l’environnement lorsque des commandes `mix` ou `make` sont exécutées. Plusieurs moyens sont à votre disposition pour ça, mais l’utilisation de [`nv`](https://github.com/jcouture/nv) est recommandée puisqu’elle fonctionne _out of the box_ avec les fichiers `.env.*`. 36 | 37 | ### Mise en place initiale 38 | 39 | 1. Créer `.env.dev.local` et `.env.test.local` à partir des valeurs vides de [`.env.dev`](./.env.dev) and [`.env.test`](./.env.test) 40 | 2. Installer les dépendances Mix et NPM avec `make dependencies` 41 | 3. Générer des valeurs pour les _secrets_ dans [`.env.dev`](./.env.dev) avec `mix phx.gen.secret` 42 | 43 | Ensuite, avec les variables de `.env.dev` et `.env.dev.local` présentes dans l’environnement : 44 | 45 | 1. Créer et migrer la base de données avec `mix ecto.setup` 46 | 2. Démarrer le serveur Phoenix avec `make run` 47 | 48 | ### Commandes `make` 49 | 50 | Un fichier `Makefile` est présent à la racine et expose des tâches communes. La liste de ces tâches est disponible via `make help`. 51 | 52 | ### Base de données 53 | 54 | Pour éviter de rouler PostgreSQL localement sur votre machine, un fichier `docker-compose.yml` est inclus pour permettre le démarrage d’un serveur PostgreSQL dans un _container_ Docker avec `docker-compose up postgresql`. 55 | 56 | ### Tests 57 | 58 | La suite de tests peut être exécutée avec `make test` et le niveau de couverture de celle-ci peut être calculé et validé avec `make check-code-coverage`. 59 | 60 | ### _Linting_ et _formatting_ 61 | 62 | Plusieurs outils de _linting_ et de _formatting_ peuvent être exécutés pour s’assurer du respect des bonnes pratiques de code : 63 | 64 | - `make lint-elixir` s’assure que le code Elixir respecte nos bonnes pratiques 65 | - `make lint-scripts` s’assure que le code JavaScript respecte nos bonnes pratiques 66 | - `make lint-styles` s’assure que le code SCSS respecte nos bonnes pratiques 67 | - `make check-format` valide que le code est bien formatté 68 | - `make format` formatte les fichiers en utilisant Prettier et `mix format` 69 | 70 | ### Intégration continue 71 | 72 | Le workflow `.github/workflows/ci.yaml` s’assure que le code du projet est en bon état à chaque pull request et `push` sur une branche. 73 | 74 | ## 🏗 Code et architecture 75 | 76 | … 77 | 78 | ## 🔭 Améliorations possibles 79 | 80 | | Description | Priorité | Complexité | Idées | 81 | | ----------- | -------- | ---------- | ----- | 82 | | … | … | … | … | 83 | 84 | ## 🚑 Problèmes et solutions 85 | 86 | ### Disponibilité du système 87 | 88 | Le projet expose une route `GET /ping` qui retourne une réponse HTTP `200 OK` dès que le serveur est prêt à recevoir des requêtes. La réponse contient également la version du projet à des fin de déboguage. 89 | 90 | ### Santé du système 91 | 92 | Le projet expose une route `GET /health` qui sert le module `ElixirBoilerplateHealth`. Ce module contient différents _checks_ qui s’assurent que l’application et ses services dépendants sont en santé. 93 | 94 | | Nom | Description | 95 | | ------ | ----------------------------------- | 96 | | `NOOP` | Check _check_ est toujours en santé | 97 | 98 | ### Métriques 99 | 100 | Le projet expose un tableau de bord [Telemetry UI](https://github.com/mirego/telemetry_ui) via la route `GET /metrics`. Les métriques sont configurables [ici](lib/elixir_boilerplate/telemetry_ui/telemetry_ui.ex). 101 | 102 | ## 🚀 Deploiement 103 | 104 | ### Versions et branches 105 | 106 | Chaque déploiement est effectué à partir d’un tag Git. La version du _codebase_ est gérée avec [`incr`](https://github.com/jcouture/incr). 107 | 108 | ### _Container_ 109 | 110 | Un _container_ Docker exposant une _release OTP_ peut être créé avec `make build`, testé avec `docker-compose up application` et poussé dans un registre avec `make push`. 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
    2 | 3 |


    This repository is the stable base upon which we build our Elixir projects at Mirego.
    We want to share it with the world so you can build awesome Elixir applications too.

    4 | 5 |
    6 | 7 | ## Introduction 8 | 9 | To learn more about _why_ we created and maintain this boilerplate project, read our [blog post](https://shift.mirego.com/en/boilerplate-projects). 10 | 11 | ## Content 12 | 13 | This boilerplate comes with batteries included, you’ll find: 14 | 15 | - [Phoenix](https://phoenixframework.org), the battle-tested production-ready web framework 16 | - Database integration using [Ecto](https://hexdocs.pm/ecto) 17 | - GraphQL API setup with [Absinthe](https://hexdocs.pm/absinthe), [Absinthe.Plug](https://hexdocs.pm/absinthe_plug), [Dataloader](https://hexdocs.pm/dataloader), [AbsintheErrorPayload](https://hexdocs.pm/absinthe_error_payload) and [AbsintheSecurity](https://hexdocs.pm/absinthe_security) 18 | - Translations with [Gettext](https://hexdocs.pm/gettext) and [Accent](https://www.accent.reviews) (using a scheduled GitHub Actions [workflow](./.github/workflows/accent.yaml)) 19 | - [ExUnit](https://hexdocs.pm/ex_unit) tests, factories using [ExMachina](https://hexdocs.pm/ex_machina) and code coverage using [ExCoveralls](https://hexdocs.pm/excoveralls) 20 | - CORS management with [Corsica](https://github.com/whatyouhide/corsica) 21 | - Opinionated linting with [Credo](http://credo-ci.org) 22 | - Security scanning with [MixAudit](https://hex.pm/packages/mix_audit) and [Sobelow](https://hexdocs.pm/sobelow) 23 | - Healthcheck setup with [plug_checkup](https://hexdocs.pm/plug_checkup) 24 | - OTP release using [`mix release`](https://hexdocs.pm/mix/Mix.Tasks.Release.html) and [Docker](https://www.docker.com) 25 | - Useful utilities for web features: Basic authentication with [BasicAuth](https://hexdocs.pm/plug/Plug.BasicAuth.html), canonical host with [PlugCanonicalHost](https://hexdocs.pm/plug_canonical_host), etc. 26 | - Error reporting with [Sentry](https://hexdocs.pm/sentry) 27 | - A clean and useful `README.md` template (in both [english](./BOILERPLATE_README.md) and [french](./BOILERPLATE_README.fr.md)) 28 | - Dashboard metrics using [TelemetryUI](https://github.com/mirego/telemetry_ui) 29 | 30 | ## Usage 31 | 32 | ### With GitHub template 33 | 34 | 1. Click on the [**Use this template**](https://github.com/mirego/elixir-boilerplate/generate) button to create a new repository 35 | 2. Clone your newly created project (`git clone https://github.com/you/repo.git`) 36 | 3. Run the boilerplate setup script (`./boilerplate-setup.sh YourProjectName`) 37 | 4. Commit the changes (`git commit -a -m "Rename elixir-boilerplate parts"`) 38 | 39 | ### Without GitHub template 40 | 41 | 1. Clone this project (`git clone https://github.com/mirego/elixir-boilerplate.git`) 42 | 2. Delete the internal Git directory (`rm -rf .git`) 43 | 3. Run the boilerplate setup script (`./boilerplate-setup.sh YourProjectName`) 44 | 4. Create a new Git repository (`git init`) 45 | 5. Create the initial Git commit (`git commit -a -m "Initial commit"`) 46 | 47 | ## Preferred libraries 48 | 49 | Some batteries aren’t included since all projects have their own needs and requirements. Here’s a list of our preferred libraries to help you get started: 50 | 51 | | Category | Libraries | 52 | | --------------------------- | -------------------------------------------------------------------------------------- | 53 | | Authentication | [`ueberauth`](https://hex.pm/packages/ueberauth), [`pow`](https://hex.pm/packages/pow) | 54 | | Asynchronous job processing | [`oban`](https://hex.pm/packages/oban) | 55 | | Emails | [`bamboo`](https://hex.pm/packages/bamboo), [`swoosh`](https://hex.pm/packages/swoosh) | 56 | | File upload | [`waffle`](https://hex.pm/packages/waffle) | 57 | | HTTP client | [`tesla`](https://hex.pm/packages/tesla) | 58 | | HTML parsing | [`floki`](https://hex.pm/packages/floki) | 59 | | Pagination | [`scrivener`](https://hex.pm/packages/scrivener) | 60 | | Mocks | [`mox`](https://hex.pm/packages/mox), [`mimic`](https://hex.pm/packages/mimic) | 61 | | Search | [`elasticsearch`](https://hex.pm/packages/elasticsearch) | 62 | 63 | ## License 64 | 65 | Elixir Boilerplate is © 2017-2020 [Mirego](https://www.mirego.com) and may be freely distributed under the [New BSD license](http://opensource.org/licenses/BSD-3-Clause). See the [`LICENSE.md`](https://github.com/mirego/elixir-boilerplate/blob/main/LICENSE.md) file. 66 | 67 | The drop logo is based on [this lovely icon by Creative Stall](https://thenounproject.com/term/drop/174999), from The Noun Project. Used under a [Creative Commons BY 3.0](http://creativecommons.org/licenses/by/3.0/) license. 68 | 69 | ## About Mirego 70 | 71 | [Mirego](https://www.mirego.com) is a team of passionate people who believe that work is a place where you can innovate and have fun. We’re a team of [talented people](https://life.mirego.com) who imagine and build beautiful Web and mobile applications. We come together to share ideas and [change the world](http://www.mirego.org). 72 | 73 | We also [love open-source software](https://open.mirego.com) and we try to give back to the community as much as we can. 74 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | alias Credo.Check.Refactor.ABCSize 2 | 3 | %{ 4 | configs: [ 5 | %{ 6 | name: "default", 7 | files: %{ 8 | included: ["*.exs", "lib/", "priv/", "config/", "rel/", "test/"], 9 | excluded: [~r"/_build/", ~r"/deps/"] 10 | }, 11 | plugins: [], 12 | requires: [], 13 | strict: true, 14 | parse_timeout: 5000, 15 | color: true, 16 | checks: %{ 17 | enabled: [ 18 | # Consistency Checks 19 | {Credo.Check.Consistency.ExceptionNames, []}, 20 | {Credo.Check.Consistency.LineEndings, []}, 21 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 22 | {Credo.Check.Consistency.SpaceInParentheses, []}, 23 | {Credo.Check.Consistency.TabsOrSpaces, []}, 24 | 25 | # Design Checks 26 | {Credo.Check.Design.AliasUsage, [if_nested_deeper_than: 1, if_called_more_often_than: 2]}, 27 | {Credo.Check.Design.TagTODO, []}, 28 | {Credo.Check.Design.TagFIXME, []}, 29 | 30 | # Readability Checks 31 | {Credo.Check.Readability.FunctionNames, []}, 32 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 200]}, 33 | {Credo.Check.Readability.ModuleAttributeNames, []}, 34 | {Credo.Check.Readability.ModuleNames, []}, 35 | {Credo.Check.Readability.ParenthesesInCondition, []}, 36 | {Credo.Check.Readability.PredicateFunctionNames, []}, 37 | {Credo.Check.Readability.RedundantBlankLines, []}, 38 | {Credo.Check.Readability.Semicolons, []}, 39 | {Credo.Check.Readability.SpaceAfterCommas, []}, 40 | {Credo.Check.Readability.StringSigils, []}, 41 | {Credo.Check.Readability.TrailingBlankLine, []}, 42 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 43 | {Credo.Check.Readability.VariableNames, []}, 44 | 45 | # Refactoring Opportunities 46 | {ABCSize, max_size: 40}, 47 | {Credo.Check.Refactor.Apply, []}, 48 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 49 | {Credo.Check.Refactor.FunctionArity, []}, 50 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 51 | {Credo.Check.Refactor.MatchInCondition, []}, 52 | {Credo.Check.Refactor.Nesting, []}, 53 | {Credo.Check.Refactor.FilterFilter, []}, 54 | {Credo.Check.Refactor.RejectReject, []}, 55 | 56 | # Warnings 57 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 58 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 59 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 60 | {Credo.Check.Warning.IExPry, []}, 61 | {Credo.Check.Warning.IoInspect, []}, 62 | {Credo.Check.Warning.OperationOnSameValues, []}, 63 | {Credo.Check.Warning.OperationWithConstantResult, []}, 64 | {Credo.Check.Warning.RaiseInsideRescue, []}, 65 | {Credo.Check.Warning.SpecWithStruct, []}, 66 | {Credo.Check.Warning.WrongTestFileExtension, []}, 67 | {Credo.Check.Warning.UnusedEnumOperation, []}, 68 | {Credo.Check.Warning.UnusedFileOperation, []}, 69 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 70 | {Credo.Check.Warning.UnusedListOperation, []}, 71 | {Credo.Check.Warning.UnusedPathOperation, []}, 72 | {Credo.Check.Warning.UnusedRegexOperation, []}, 73 | {Credo.Check.Warning.UnusedStringOperation, []}, 74 | {Credo.Check.Warning.UnusedTupleOperation, []}, 75 | {Credo.Check.Warning.UnsafeExec, []}, 76 | 77 | # Naming 78 | {CredoEnvvar.Check.Warning.EnvironmentVariablesAtCompileTime}, 79 | {CredoNaming.Check.Warning.AvoidSpecificTermsInModuleNames, terms: ["Manager", "Fetcher", "Builder", "Persister", "Serializer", ~r/^Helpers?$/i, ~r/^Utils?$/i]}, 80 | {CredoNaming.Check.Consistency.ModuleFilename, 81 | excluded_paths: ["config", "mix.exs", "priv", "test/support"], acronyms: [{"ElixirBoilerplateGraphQL", "elixir_boilerplate_graphql"}, {"GraphQL", "graphql"}]}, 82 | 83 | # Database 84 | {ExcellentMigrations.CredoCheck.MigrationsSafety, []} 85 | ], 86 | disabled: [ 87 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 88 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 89 | {Credo.Check.Consistency.UnusedVariableNames, []}, 90 | {Credo.Check.Design.DuplicatedCode, []}, 91 | {Credo.Check.Design.SkipTestWithoutComment, []}, 92 | {Credo.Check.Readability.AliasAs, []}, 93 | {Credo.Check.Readability.AliasOrder, []}, 94 | {Credo.Check.Readability.BlockPipe, []}, 95 | {Credo.Check.Readability.ImplTrue, []}, 96 | {Credo.Check.Readability.LargeNumbers, []}, 97 | {Credo.Check.Readability.ModuleDoc, false}, 98 | {Credo.Check.Readability.MultiAlias, []}, 99 | {Credo.Check.Readability.NestedFunctionCalls, []}, 100 | {Credo.Check.Readability.OneArityFunctionInPipe, false}, 101 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 102 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 103 | {Credo.Check.Readability.PreferImplicitTry, []}, 104 | {Credo.Check.Readability.SeparateAliasRequire, []}, 105 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 106 | {Credo.Check.Readability.SinglePipe, []}, 107 | {Credo.Check.Readability.Specs, []}, 108 | {Credo.Check.Readability.StrictModuleLayout, []}, 109 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 110 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 111 | {Credo.Check.Readability.WithSingleClause, []}, 112 | {ABCSize, []}, 113 | {Credo.Check.Refactor.AppendSingleItem, []}, 114 | {Credo.Check.Refactor.CaseTrivialMatches, false}, 115 | {Credo.Check.Refactor.CondStatements, []}, 116 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 117 | {Credo.Check.Refactor.FilterCount, false}, 118 | {Credo.Check.Refactor.FilterReject, []}, 119 | {Credo.Check.Refactor.IoPuts, []}, 120 | {Credo.Check.Refactor.MapInto, false}, 121 | {Credo.Check.Refactor.MapJoin, []}, 122 | {Credo.Check.Refactor.MapMap, []}, 123 | {Credo.Check.Refactor.ModuleDependencies, []}, 124 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 125 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 126 | {Credo.Check.Refactor.NegatedIsNil, []}, 127 | {Credo.Check.Refactor.PipeChainStart, [excluded_argument_types: ~w(atom binary fn keyword)a, excluded_functions: ~w(from)]}, 128 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 129 | {Credo.Check.Refactor.RejectFilter, []}, 130 | {Credo.Check.Refactor.UnlessWithElse, []}, 131 | {Credo.Check.Refactor.VariableRebinding, []}, 132 | {Credo.Check.Refactor.WithClauses, []}, 133 | {Credo.Check.Warning.LazyLogging, []}, 134 | {Credo.Check.Warning.LeakyEnvironment, []}, 135 | {Credo.Check.Warning.MapGetUnsafePass, []}, 136 | {Credo.Check.Warning.MixEnv, []}, 137 | {Credo.Check.Warning.UnsafeToAtom, []} 138 | ] 139 | } 140 | } 141 | ] 142 | } 143 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate/telemetry_ui/telemetry_ui.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirBoilerplate.TelemetryUI do 2 | @moduledoc false 3 | import TelemetryUI.Metrics 4 | 5 | def config do 6 | ui_options = [metrics_class: "grid-cols-8 gap-4"] 7 | 8 | [ 9 | metrics: [ 10 | {"HTTP", http_metrics(), ui_options: ui_options}, 11 | {"GraphQL", graphql_metrics(), ui_options: [metrics_class: "grid-cols-8 gap-4"]}, 12 | {"Absinthe", absinthe_metrics(), ui_options: [metrics_class: "grid-cols-8 gap-4"]}, 13 | {"Ecto", ecto_metrics(), ui_options: ui_options}, 14 | {"System", system_metrics()} 15 | ], 16 | theme: theme(), 17 | backend: backend() 18 | ] 19 | end 20 | 21 | def http_metrics do 22 | http_keep = &(&1[:route] not in ~w(/metrics)) 23 | 24 | [ 25 | counter("phoenix.router_dispatch.stop.duration", 26 | description: "Number of requests", 27 | keep: http_keep, 28 | unit: {:native, :millisecond}, 29 | ui_options: [class: "col-span-3", unit: " requests"] 30 | ), 31 | count_over_time("phoenix.router_dispatch.stop.duration", 32 | description: "Number of requests over time", 33 | keep: http_keep, 34 | unit: {:native, :millisecond}, 35 | ui_options: [class: "col-span-5", unit: " requests"] 36 | ), 37 | average("phoenix.router_dispatch.stop.duration", 38 | description: "Requests duration", 39 | keep: http_keep, 40 | unit: {:native, :millisecond}, 41 | ui_options: [class: "col-span-3", unit: " ms"] 42 | ), 43 | average_over_time("phoenix.router_dispatch.stop.duration", 44 | description: "Requests duration over time", 45 | keep: http_keep, 46 | unit: {:native, :millisecond}, 47 | ui_options: [class: "col-span-5", unit: " ms"] 48 | ), 49 | count_over_time("phoenix.router_dispatch.stop.duration", 50 | description: "HTTP requests count per route", 51 | keep: http_keep, 52 | tags: [:route], 53 | unit: {:native, :millisecond}, 54 | ui_options: [unit: " requests"], 55 | reporter_options: [class: "col-span-4"] 56 | ), 57 | counter("phoenix.router_dispatch.stop.duration", 58 | description: "Count HTTP requests by route", 59 | keep: http_keep, 60 | tags: [:route], 61 | unit: {:native, :millisecond}, 62 | ui_options: [unit: " requests"], 63 | reporter_options: [class: "col-span-4"] 64 | ), 65 | average_over_time("phoenix.router_dispatch.stop.duration", 66 | description: "HTTP requests duration per route", 67 | keep: http_keep, 68 | tags: [:route], 69 | unit: {:native, :millisecond}, 70 | reporter_options: [class: "col-span-4"] 71 | ), 72 | distribution("phoenix.router_dispatch.stop.duration", 73 | description: "Requests duration", 74 | keep: http_keep, 75 | unit: {:native, :millisecond}, 76 | reporter_options: [buckets: [0, 100, 500, 2000]] 77 | ) 78 | ] 79 | end 80 | 81 | defp ecto_metrics do 82 | ecto_keep = &(&1[:source] not in [nil, ""] and not String.starts_with?(&1[:source], "oban") and not String.starts_with?(&1[:source], "telemetry_ui")) 83 | 84 | [ 85 | average("elixir_boilerplate.repo.query.total_time", 86 | description: "Database query total time", 87 | keep: ecto_keep, 88 | unit: {:native, :millisecond}, 89 | ui_options: [class: "col-span-3", unit: " ms"] 90 | ), 91 | average_over_time("elixir_boilerplate.repo.query.total_time", 92 | description: "Database query total time over time", 93 | keep: ecto_keep, 94 | unit: {:native, :millisecond}, 95 | ui_options: [class: "col-span-5", unit: " ms"] 96 | ), 97 | average("elixir_boilerplate.repo.query.total_time", 98 | description: "Database query total time per source", 99 | keep: ecto_keep, 100 | tags: [:source], 101 | unit: {:native, :millisecond}, 102 | ui_options: [class: "col-span-full", unit: " ms"] 103 | ) 104 | ] 105 | end 106 | 107 | defp absinthe_metrics do 108 | absinthe_tag_values = fn metadata -> 109 | operation_name = 110 | metadata.blueprint.operations 111 | |> Enum.map(& &1.name) 112 | |> Enum.uniq() 113 | |> Enum.join(",") 114 | 115 | %{operation_name: operation_name} 116 | end 117 | 118 | [ 119 | average("absinthe.execute.operation.stop.duration", 120 | description: "Absinthe operation duration", 121 | unit: {:native, :millisecond}, 122 | ui_options: [class: "col-span-3", unit: " ms"] 123 | ), 124 | average_over_time("absinthe.execute.operation.stop.duration", 125 | description: "Absinthe operation duration over time", 126 | unit: {:native, :millisecond}, 127 | ui_options: [class: "col-span-5", unit: " ms"] 128 | ), 129 | counter("absinthe.execute.operation.stop.duration", 130 | description: "Count Absinthe executions per operation", 131 | tags: [:operation_name], 132 | tag_values: absinthe_tag_values, 133 | unit: {:native, :millisecond} 134 | ), 135 | average_over_time("absinthe.execute.operation.stop.duration", 136 | description: "Absinthe duration per operation", 137 | tags: [:operation_name], 138 | tag_values: absinthe_tag_values, 139 | unit: {:native, :millisecond} 140 | ) 141 | ] 142 | end 143 | 144 | defp graphql_metrics do 145 | graphql_keep = &(&1[:route] in ~w(/graphql)) 146 | 147 | graphql_tag_values = fn metadata -> 148 | operation_name = 149 | case metadata.conn.params do 150 | %{"_json" => json} -> 151 | json 152 | |> Enum.map(& &1["operationName"]) 153 | |> Enum.uniq() 154 | |> Enum.join(",") 155 | 156 | _ -> 157 | nil 158 | end 159 | 160 | %{operation_name: operation_name} 161 | end 162 | 163 | [ 164 | counter("graphql.router_dispatch.duration", 165 | event_name: [:phoenix, :router_dispatch, :stop], 166 | description: "Number of GraphQL requests", 167 | keep: graphql_keep, 168 | unit: {:native, :millisecond}, 169 | ui_options: [class: "col-span-3", unit: " requests"] 170 | ), 171 | count_over_time("graphql.router_dispatch.duration", 172 | event_name: [:phoenix, :router_dispatch, :stop], 173 | description: "Number of GraphQL requests over time", 174 | keep: graphql_keep, 175 | unit: {:native, :millisecond}, 176 | ui_options: [class: "col-span-5", unit: " requests"] 177 | ), 178 | average("graphql.router_dispatch.duration", 179 | event_name: [:phoenix, :router_dispatch, :stop], 180 | description: "GraphQL requests duration", 181 | keep: graphql_keep, 182 | unit: {:native, :millisecond}, 183 | ui_options: [class: "col-span-3", unit: " ms"] 184 | ), 185 | average_over_time("graphql.router_dispatch.duration", 186 | event_name: [:phoenix, :router_dispatch, :stop], 187 | description: "GraphQL requests duration over time", 188 | keep: graphql_keep, 189 | unit: {:native, :millisecond}, 190 | ui_options: [class: "col-span-5", unit: " ms"] 191 | ), 192 | count_over_time("graphql.router_dispatch.duration", 193 | event_name: [:phoenix, :router_dispatch, :stop], 194 | description: "GraphQL requests count per operation", 195 | keep: graphql_keep, 196 | tag_values: graphql_tag_values, 197 | tags: [:operation_name], 198 | unit: {:native, :millisecond}, 199 | ui_options: [unit: " requests"], 200 | reporter_options: [class: "col-span-4"] 201 | ), 202 | counter("graphql.router_dispatch.duration", 203 | event_name: [:phoenix, :router_dispatch, :stop], 204 | description: "Count GraphQL requests by operation", 205 | keep: graphql_keep, 206 | tag_values: graphql_tag_values, 207 | tags: [:operation_name], 208 | unit: {:native, :millisecond}, 209 | ui_options: [unit: " requests"], 210 | reporter_options: [class: "col-span-4"] 211 | ), 212 | average("graphql.router_dispatch.duration", 213 | event_name: [:phoenix, :router_dispatch, :stop], 214 | description: "GraphQL requests duration per operation", 215 | keep: graphql_keep, 216 | tag_values: graphql_tag_values, 217 | tags: [:operation_name], 218 | unit: {:native, :millisecond}, 219 | reporter_options: [class: "col-span-4"] 220 | ), 221 | distribution("graphql.router_dispatch.duration", 222 | event_name: [:phoenix, :router_dispatch, :stop], 223 | description: "GraphQL requests duration", 224 | keep: graphql_keep, 225 | unit: {:native, :millisecond}, 226 | reporter_options: [buckets: [0, 100, 500, 2000]] 227 | ) 228 | ] 229 | end 230 | 231 | defp system_metrics do 232 | [ 233 | last_value("vm.memory.total", unit: {:byte, :megabyte}) 234 | ] 235 | end 236 | 237 | defp theme do 238 | %{ 239 | header_color: "#deb7ff", 240 | primary_color: "#8549a7", 241 | title: "ElixirBoilerplate", 242 | share_key: share_key(), 243 | logo: """ 244 | 245 | """ 246 | } 247 | end 248 | 249 | defp backend do 250 | %TelemetryUI.Backend.EctoPostgres{ 251 | repo: ElixirBoilerplate.Repo, 252 | pruner_threshold: [months: -1], 253 | pruner_interval_ms: 84_000, 254 | max_buffer_size: 10_000, 255 | flush_interval_ms: 30_000, 256 | verbose: false 257 | } 258 | end 259 | 260 | defp share_key, do: Application.get_env(:elixir_boilerplate, __MODULE__)[:share_key] 261 | end 262 | -------------------------------------------------------------------------------- /lib/elixir_boilerplate_web/home/templates/header.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

    This repository is the stable base upon which we build our Elixir projects at Mirego.
    We want to share it with the world so you can build awesome Elixir applications too.

    6 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "absinthe": {:hex, :absinthe, "1.7.10", "b33471b593260f148d05e4d771d1857e07b70a680f89cfa75184098bef4ec893", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ffda95735364c041a65a4b0e02ffb04eabb1e52ab664fa7eeecefb341449e8c2"}, 3 | "absinthe_error_payload": {:hex, :absinthe_error_payload, "1.2.0", "ca1dc4311190dedea650e41b996064be6aaf2f1e8d7b4850842eb1ce89d9d461", [:make, :mix], [{:absinthe, "~> 1.3", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, "~> 3.1", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "d9b9201a2710a2c09da7a5a35a2d8aff0b0c9253875ab629c45747e13f4b1e4a"}, 4 | "absinthe_plug": {:hex, :absinthe_plug, "1.5.9", "4f66fd46aecf969b349dd94853e6132db6d832ae6a4b951312b6926ad4ee7ca3", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dcdc84334b0e9e2cd439bd2653678a822623f212c71088edf0a4a7d03f1fa225"}, 5 | "absinthe_security": {:hex, :absinthe_security, "0.1.0", "1584c219f5162f5297711c4fc8efb67828f5494f29f03af6530eeb01e1c50abb", [:make, :mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}], "hexpm", "803945e854c6445529424f60586a7e8358bc3e07738099ba5630edbddf0457d8"}, 6 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 7 | "castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"}, 8 | "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, 9 | "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, 10 | "corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"}, 11 | "cowboy": {:hex, :cowboy, "2.14.1", "031d338393e5a128a7de9613b4a0558aabc31b07082004abecb27cac790f5cd6", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e5310d5afd478ba90b1fed4fcdbc0230082b4510009505c586725c30b44e356f"}, 12 | "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"}, 13 | "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, 14 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 15 | "credo_envvar": {:hex, :credo_envvar, "0.1.4", "40817c10334e400f031012c0510bfa0d8725c19d867e4ae39cf14f2cbebc3b20", [:mix], [{:credo, "~> 1.0", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "5055cdb4bcbaf7d423bc2bb3ac62b4e2d825e2b1e816884c468dee59d0363009"}, 16 | "credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"}, 17 | "dataloader": {:hex, :dataloader, "2.0.2", "c45075e0692e68638a315e14f747bd8d7065fb5f38705cf980f62d4cd344401f", [:mix], [{:ecto, ">= 3.4.3 and < 4.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4c6cabc0b55e96e7de74d14bf37f4a5786f0ab69aa06764a1f39dda40079b098"}, 18 | "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, 19 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 20 | "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, 21 | "ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"}, 22 | "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, 23 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 24 | "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, 25 | "ex_machina": {:hex, :ex_machina, "2.8.0", "a0e847b5712065055ec3255840e2c78ef9366634d62390839d4880483be38abe", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "79fe1a9c64c0c1c1fab6c4fa5d871682cb90de5885320c187d117004627a7729"}, 26 | "excellent_migrations": {:hex, :excellent_migrations, "0.1.9", "922686ac64ba228002e22c42c48c7a07ab43199d28a44e5f8ac431049d52ceba", [:mix], [{:credo, "~> 1.5", [hex: :credo, repo: "hexpm", optional: true]}], "hexpm", "6d571987dd1267812620da0cfe82459d27c49c74facaccb0e3c24c6d5f4ceb87"}, 27 | "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, 28 | "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, 29 | "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, 30 | "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 31 | "gettext": {:hex, :gettext, "1.0.0", "f8853ecb33e96361288f6239fafcfd50214b0a88ec38b5e452138d815d4877d8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "cc8196640756894a4fd75606067bed41a9863c0db09d6d6cc576e6170cffaa74"}, 32 | "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, 33 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 34 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 35 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 36 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 37 | "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, 38 | "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, 39 | "new_relic_absinthe": {:hex, :new_relic_absinthe, "0.0.5", "fbf8e7db64c01d78bebcd38834583804ed44dc173cfd033cc5623b438b62e43d", [:mix], [{:new_relic_agent, ">= 1.31.0", [hex: :new_relic_agent, repo: "hexpm", optional: false]}], "hexpm", "5e700f035d799f2253c9967956ae9c82724a1fb9e40db3a02aa7b5895bb7034e"}, 40 | "new_relic_agent": {:hex, :new_relic_agent, "1.40.2", "ee9eca0c5e41230ba3654c9fc2802b07854cd274a3ea508ee0aba519937d53a1", [:mix], [{:absinthe, ">= 1.6.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:castore, ">= 0.1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ecto, ">= 3.9.5", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, ">= 3.4.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:finch, ">= 0.18.0", [hex: :finch, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:oban, ">= 2.0.0", [hex: :oban, repo: "hexpm", optional: true]}, {:phoenix, ">= 1.5.5", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.10.4", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.4.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:redix, ">= 0.11.0", [hex: :redix, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fa88cb33db0721ef8dcf65e192aa3592a684b446e9439ea836cfbd4231ead425"}, 41 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 42 | "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, 43 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 44 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 45 | "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"}, 46 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, 47 | "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, 48 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"}, 49 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.13", "11f48f8fbe5d7d0731d4e122a692e0f9ae8d5f98c54d573d29046833c34eada3", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9d06e93573b8419ef8ee832b4a9bfe0def10b15f7c129ea477dfa54f0136f7ec"}, 50 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 51 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 52 | "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, 53 | "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, 54 | "plug_canonical_host": {:hex, :plug_canonical_host, "2.0.3", "3d96c3340cc8a434eb6758a4a34de6c152bd781be96bb8439545da2d17ecf576", [:make, :mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "aca98ac6d0036391b84d5a40af6f946c839fb0d588bf0064029a2e8931431ea6"}, 55 | "plug_checkup": {:hex, :plug_checkup, "0.6.0", "595d567a9c7d4504460800794641d7145b4723b0288bd78c411d2657eb76bc4b", [:mix], [{:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7362c7161e4cb60eb7adc8c2ffc65c3bc87371eec276b081bca18d4b32fb6952"}, 56 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.4", "729c752d17cf364e2b8da5bdb34fb5804f56251e88bb602aff48ae0bd8673d11", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9b85632bd7012615bae0a5d70084deb1b25d2bcbb32cab82d1e9a1e023168aa3"}, 57 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 58 | "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, 59 | "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, 60 | "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.3", "4e741024b0b097fe783add06e53ae9a6f23ddc78df1010f215df0c02915ef5a8", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c23f5f33cb6608542de4d04faf0f0291458c352a4648e4d28d17ee1098cddcc4"}, 61 | "sentry": {:hex, :sentry, "10.10.0", "d058b635f3796947545c8057a42996f6dbefd12152da947209b56d16af41b161", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 0.3.0 or ~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "7c7ddd3cfdd63fcee53b1e28f9a653037e6927b2b1dbd300b7aeee9687c7a8f6"}, 62 | "sobelow": {:hex, :sobelow, "0.14.0", "dd82aae8f72503f924fe9dd97ffe4ca694d2f17ec463dcfd365987c9752af6ee", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7ecf91e298acfd9b24f5d761f19e8f6e6ac585b9387fb6301023f1f2cd5eed5f"}, 63 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 64 | "styler": {:hex, :styler, "1.9.1", "e30f0e909c02c686c75e47c07a76986483525eeb23c4d136f00dfa1c25fc6499", [:mix], [], "hexpm", "f583bedd92515245801f9ad504766255a27ecd5714fc4f1fd607de0eb951e1cf"}, 65 | "table": {:hex, :table, "0.1.2", "87ad1125f5b70c5dea0307aa633194083eb5182ec537efc94e96af08937e14a8", [:mix], [], "hexpm", "7e99bc7efef806315c7e65640724bf165c3061cdc5d854060f74468367065029"}, 66 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 67 | "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, 68 | "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, 69 | "telemetry_ui": {:hex, :telemetry_ui, "5.1.0", "7a94cb2bc87ae64112bf58c38754d55d03e369da465be12bb28918ee2fd3b37e", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.13", [hex: :oban, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.4", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, ">= 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}, {:timex, "~> 3.7", [hex: :timex, repo: "hexpm", optional: false]}, {:vega_lite, "~> 0.1", [hex: :vega_lite, repo: "hexpm", optional: false]}, {:vega_lite_convert, "~> 1.0", [hex: :vega_lite_convert, repo: "hexpm", optional: false]}], "hexpm", "c38bf4c725c2e1ee39b10dae65a27f7caffa3f8ca1f11670060fa2c91353ee4b"}, 70 | "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"}, 71 | "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, 72 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, 73 | "vega_lite": {:hex, :vega_lite, "0.1.11", "2b261d21618f6fa9f63bb4542f0262982d2e40aea3f83e935788fe172902b3c2", [:mix], [{:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: false]}], "hexpm", "d18c3f11369c14bdf36ab53010c06bf5505c221cbcb32faac7420cf6926b3c50"}, 74 | "vega_lite_convert": {:hex, :vega_lite_convert, "1.0.1", "1cc0309998c10bce9d944ae631938a433e9cad4ccf7344f9a192d9ddcab1bd93", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.4", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}, {:vega_lite, ">= 0.0.0", [hex: :vega_lite, repo: "hexpm", optional: false]}], "hexpm", "1e12b4ef3943510d3597e4876a10c6ab333b8ee71204b5e571605d80aa3cbd76"}, 75 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 76 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 77 | "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, 78 | "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, 79 | } 80 | --------------------------------------------------------------------------------