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.
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.