From f5681121068b642a0e9cd691bce94fd1e7549c6d Mon Sep 17 00:00:00 2001 From: Andrew Berrien <74077809+APB9785@users.noreply.github.com> Date: Tue, 17 Oct 2023 15:23:14 -0500 Subject: [PATCH] Error Pages (#331) * Beacon install integration test * Test data_source, mixfile, seeds * Credo * Fixes * Bump tailwind version * Idempotence tests * Update template * Resolve compile warnings * Schema * Context functions * Migration * Update docs * Inject in mix task * Test * Error pages backend * Minor fixes * Fix typespec * Unique index * Add test for create error * Use error layout * Small doc fixes * Validate error status codes * Switch error pages to module recompilation * CI fixes * Fix test fixtures * render * test render/1 * publish default layouts * Improve test fixture * Test custom error page * PubSub * Still not working * render error page with root layout compile a function for each part of the layout tree: root, layout, and page where root takes the content of page+layout * fix layout path * pass @conn to root_layout * test ErrorHTML * Check render and layout for my_component * fix tests and cleanup * add logger when error page render fails * use Phoenix.HTML.Engine * add tests rendering my_component * remove support for components in error pages It's possible to load those but requires injecting the LivePage env, which is risky because a server error may happen due to internals or even due to the components used in the pages, causing an endless loop. So for now we won't load components due to the risk they impose but we can revisit this if needed or if we find a safer approach. * broadcast error_page_updated event only on update_error_page/2 * fix dialyzer * fix dialyzer 2 * compile error page content and reload css --------- Co-authored-by: Leandro Pereira --- dev.exs | 5 +- lib/beacon/content.ex | 148 +++++++++++++++++- lib/beacon/content/error_page.ex | 68 ++++++++ lib/beacon/content/layout.ex | 3 + lib/beacon/loader.ex | 106 +++++++++++-- lib/beacon/loader/error_page_module_loader.ex | 67 ++++++++ lib/beacon/pub_sub.ex | 32 ++++ lib/beacon/tailwind_compiler.ex | 7 + lib/beacon/template/heex.ex | 23 +-- lib/beacon/utils.ex | 8 + lib/beacon_web/controllers/error_html.ex | 25 ++- lib/mix/tasks/install.ex | 32 ++++ mix.exs | 4 +- priv/layouts/runtime_error.html.heex | 13 ++ .../20230816195326_create_error_pages.exs | 17 ++ test/beacon/content_test.exs | 43 +++++ .../loader/error_page_module_loader_test.exs | 143 +++++++++++++++++ .../controllers/error_html_test.exs | 13 ++ test/beacon_web/live/page_live_test.exs | 3 +- test/mix/tasks/install_test.exs | 6 +- test/support/fixtures.ex | 14 ++ 21 files changed, 732 insertions(+), 48 deletions(-) create mode 100644 lib/beacon/content/error_page.ex create mode 100644 lib/beacon/loader/error_page_module_loader.ex create mode 100644 lib/beacon/utils.ex create mode 100644 priv/layouts/runtime_error.html.heex create mode 100644 priv/repo/migrations/20230816195326_create_error_pages.exs create mode 100644 test/beacon/loader/error_page_module_loader_test.exs create mode 100644 test/beacon_web/controllers/error_html_test.exs diff --git a/dev.exs b/dev.exs index 5c8fe641..055b07a8 100644 --- a/dev.exs +++ b/dev.exs @@ -17,12 +17,15 @@ Logger.configure(level: :debug) Application.put_env(:phoenix, :json_library, Jason) +display_error_pages? = false + Application.put_env(:beacon, SamplePhoenix.Endpoint, http: [ip: {127, 0, 0, 1}, port: 4001], server: true, live_view: [signing_salt: "aaaaaaaa"], secret_key_base: String.duplicate("a", 64), - debug_errors: true, + debug_errors: !display_error_pages?, + render_errors: [formats: [html: BeaconWeb.ErrorHTML]], check_origin: false, pubsub_server: SamplePhoenix.PubSub, live_reload: [ diff --git a/lib/beacon/content.ex b/lib/beacon/content.ex index ed594225..0d76dd5e 100644 --- a/lib/beacon/content.ex +++ b/lib/beacon/content.ex @@ -26,6 +26,7 @@ defmodule Beacon.Content do import Ecto.Query alias Beacon.Content.Component + alias Beacon.Content.ErrorPage alias Beacon.Content.Layout alias Beacon.Content.LayoutEvent alias Beacon.Content.LayoutSnapshot @@ -47,7 +48,7 @@ defmodule Beacon.Content do @doc """ Returns the list of meta tags that are applied to all pages by default. - These meta tags can be overwriten or extended on a Layout or Page level. + These meta tags can be overwritten or extended on a Layout or Page level. """ @spec default_site_meta_tags() :: [map()] def default_site_meta_tags do @@ -73,6 +74,18 @@ defmodule Beacon.Content do Layout.changeset(layout, attrs) end + @doc """ + Returns a map of attrs to load the default layout into new sites. + """ + @spec default_layout() :: map() + @doc type: :layouts + def default_layout do + %{ + title: "Default", + template: "<%= @inner_content %>" + } + end + @doc """ Creates a layout. @@ -1675,7 +1688,7 @@ defmodule Beacon.Content do Note that the `:page` assigns is made available as `assigns["page"]` (String.t) due to how Solid works. - Snipets can be used in: + Snippets can be used in: * Meta Tag value * Page Schema (structured Schema.org tags) @@ -1704,6 +1717,137 @@ defmodule Beacon.Content do end end + # ERROR PAGES + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking error page changes. + + ## Example + + iex> change_error_page(error_page, %{status: 404}) + %Ecto.Changeset{data: %ErrorPage{}} + + """ + @doc type: :error_pages + @spec change_error_page(ErrorPage.t(), map()) :: Changeset.t() + def change_error_page(%ErrorPage{} = error_page, attrs \\ %{}) do + ErrorPage.changeset(error_page, attrs) + end + + @doc """ + Returns the error page for a given site and status code, or `nil` if no matching error page exists. + """ + @doc type: :error_pages + @spec get_error_page(Site.t(), ErrorPage.error_status()) :: ErrorPage.t() | nil + def get_error_page(site, status) do + Repo.one( + from e in ErrorPage, + where: e.site == ^site, + where: e.status == ^status + ) + end + + @doc """ + Lists all error pages for a given site. + + ## Options + + * `:per_page` - limit how many records are returned, or pass `:infinity` to return all records. + * `:preloads` - a list of preloads to load. + + """ + @doc type: :error_pages + @spec list_error_pages(Site.t(), keyword()) :: [ErrorPage.t()] + def list_error_pages(site, opts \\ []) do + per_page = Keyword.get(opts, :per_page, 20) + preloads = Keyword.get(opts, :preloads, []) + + site + |> query_list_error_pages_base() + |> query_list_error_pages_limit(per_page) + |> query_list_error_pages_preloads(preloads) + |> Repo.all() + end + + defp query_list_error_pages_base(site) do + from p in ErrorPage, + where: p.site == ^site, + order_by: [asc: p.status] + end + + defp query_list_error_pages_limit(query, limit) when is_integer(limit), do: from(q in query, limit: ^limit) + defp query_list_error_pages_limit(query, :infinity = _limit), do: query + defp query_list_error_pages_limit(query, _per_page), do: from(q in query, limit: 20) + + defp query_list_error_pages_preloads(query, [_preload | _] = preloads) do + from(q in query, preload: ^preloads) + end + + defp query_list_error_pages_preloads(query, _preloads), do: query + + @doc """ + Creates a new error page. + """ + @doc type: :error_pages + @spec create_error_page(%{site: Site.t(), status: ErrorPage.error_status(), template: binary(), layout_id: Ecto.UUID.t()}) :: + {:ok, ErrorPage.t()} | {:error, Changeset.t()} + def create_error_page(attrs) do + %ErrorPage{} + |> ErrorPage.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Creates a new error page, raising if the operation fails. + """ + @doc type: :error_pages + @spec create_error_page!(%{site: Site.t(), status: ErrorPage.error_status(), template: binary(), layout_id: Ecto.UUID.t()}) :: + ErrorPage.t() + def create_error_page!(attrs) do + case create_error_page(attrs) do + {:ok, error_page} -> error_page + {:error, changeset} -> raise "failed to create error page, got: #{inspect(changeset.errors)}" + end + end + + @doc """ + Returns attr data to load the default error_pages into new sites. + """ + @spec default_error_pages() :: [map()] + @doc type: :error_pages + def default_error_pages do + for status <- [404, 500] do + %{ + status: status, + template: Plug.Conn.Status.reason_phrase(status) + } + end + end + + @doc """ + Updates an error page. + """ + @doc type: :error_pages + @spec update_error_page(ErrorPage.t(), map()) :: {:ok, ErrorPage.t()} | {:error, Changeset.t()} + def update_error_page(error_page, attrs) do + error_page + |> ErrorPage.changeset(attrs) + |> Repo.update() + |> tap(&maybe_reload_error_page/1) + end + + def maybe_reload_error_page({:ok, error_page}), do: PubSub.error_page_updated(error_page) + def maybe_reload_error_page({:error, _error_page}), do: :noop + + @doc """ + Deletes an error page. + """ + @doc type: :error_pages + @spec delete_error_page(ErrorPage.t()) :: {:ok, ErrorPage.t()} | {:error, Changeset.t()} + def delete_error_page(error_page) do + Repo.delete(error_page) + end + # PAGE EVENT HANDLERS @doc """ diff --git a/lib/beacon/content/error_page.ex b/lib/beacon/content/error_page.ex new file mode 100644 index 00000000..a36081d5 --- /dev/null +++ b/lib/beacon/content/error_page.ex @@ -0,0 +1,68 @@ +defmodule Beacon.Content.ErrorPage do + @moduledoc """ + Stores a template which can be rendered for error responses. + + An ErrorPage contains four main fields: + + * `:status` - the status code for which this ErrorPage is to be used + * `:template` - the template to be rendered + * `:site` - the Beacon site which should use this page + * `:layout_id` - the ID of the Beacon Layout which is used for rendering + + > #### Do not create or edit ErrorPages manually {: .warning} + > + > Use the public functions in `Beacon.Content` instead. + > The functions in that module guarantee that all dependencies + > are created correctly and all processes are updated. + > Manipulating data manually will most likely result + > in inconsistent behavior and crashes. + + """ + use Beacon.Schema + + import Beacon.Utils, only: [list_to_typespec: 1] + import Ecto.Changeset + + # We can use Range.to_list/1 here when we upgrade to Elixir 1.15 + @valid_error_statuses Enum.to_list(400..418) ++ + Enum.to_list(421..426) ++ + [428, 429, 431, 451] ++ + Enum.to_list(500..508) ++ + [510, 511] + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + site: Beacon.Types.Site.t(), + status: error_status(), + template: binary(), + layout_id: Ecto.UUID.t(), + layout: Beacon.Content.Layout.t(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + @type error_status :: unquote(list_to_typespec(@valid_error_statuses)) + + schema "beacon_error_pages" do + field :site, Beacon.Types.Site + field :status, :integer + field :template, :string + + belongs_to :layout, Beacon.Content.Layout + + timestamps() + end + + @doc false + def changeset(%__MODULE__{} = error_page, attrs) do + fields = ~w(status template site layout_id)a + + error_page + |> cast(attrs, fields) + |> validate_required(fields) + |> unique_constraint([:status, :site]) + |> validate_inclusion(:status, @valid_error_statuses) + end + + def valid_statuses, do: @valid_error_statuses +end diff --git a/lib/beacon/content/layout.ex b/lib/beacon/content/layout.ex index 9bcedca6..e229f9f1 100644 --- a/lib/beacon/content/layout.ex +++ b/lib/beacon/content/layout.ex @@ -49,4 +49,7 @@ defmodule Beacon.Content.Layout do |> cast(attrs, [:site, :title, :template, :meta_tags, :resource_links]) |> validate_required([:site, :title]) end + + @doc false + def fetch(layout, key), do: Map.fetch(layout, key) end diff --git a/lib/beacon/loader.ex b/lib/beacon/loader.ex index 4d5b286c..c95ddd49 100644 --- a/lib/beacon/loader.ex +++ b/lib/beacon/loader.ex @@ -1,6 +1,6 @@ defmodule Beacon.Loader do @moduledoc """ - Loader is the process resposible for loading, unloading, and reloading all resources for each site. + Loader is the process responsible for loading, unloading, and reloading all resources for each site. At start it will load all `Beacon.Content.blueprint_components/0` and existing resources stored in the database like layouts, pages, snippets, etc. @@ -15,6 +15,7 @@ defmodule Beacon.Loader do alias Beacon.Content alias Beacon.Loader.ComponentModuleLoader + alias Beacon.Loader.ErrorPageModuleLoader alias Beacon.Loader.LayoutModuleLoader alias Beacon.Loader.PageModuleLoader alias Beacon.Loader.SnippetModuleLoader @@ -34,23 +35,43 @@ defmodule Beacon.Loader do Beacon.Registry.via({site, __MODULE__}) end - @doc false - def init(config) do - if Code.ensure_loaded?(Mix.Project) and Mix.env() == :test do - :skip - else - with :ok <- populate_components(config.site) do - :ok = load_site_from_db(config.site) - end + if Code.ensure_loaded?(Mix.Project) and Mix.env() == :test do + @doc false + def init(config) do + %{site: site} = config + + # avoid compilation warnings + populate_components(nil) + + PubSub.subscribe_to_layouts(site) + PubSub.subscribe_to_pages(site) + PubSub.subscribe_to_components(site) + PubSub.subscribe_to_error_pages(site) + + {:ok, config} end + else + @doc false + def init(config) do + %{site: site} = config + + with :ok <- populate_components(site), + :ok <- populate_layouts(site), + :ok <- populate_error_pages(site) do + :ok = load_site_from_db(site) + end - PubSub.subscribe_to_layouts(config.site) - PubSub.subscribe_to_pages(config.site) - PubSub.subscribe_to_components(config.site) + PubSub.subscribe_to_layouts(site) + PubSub.subscribe_to_pages(site) + PubSub.subscribe_to_components(site) + PubSub.subscribe_to_error_pages(site) - {:ok, config} + {:ok, config} + end end + defp populate_components(nil), do: :skip + defp populate_components(site) do for attrs <- Content.blueprint_components() do case Content.list_components_by_name(site, attrs.name) do @@ -67,6 +88,42 @@ defmodule Beacon.Loader do :ok end + @doc false + def populate_layouts(site) do + case Content.get_layout_by(site, title: "Default") do + nil -> + Content.default_layout() + |> Map.put(:site, site) + |> Content.create_layout!() + |> Content.publish_layout() + + _ -> + :skip + end + + :ok + end + + @doc false + def populate_error_pages(site) do + default_layout = Content.get_layout_by(site, title: "Default") + + for attrs <- Content.default_error_pages() do + case Content.get_error_page(site, attrs.status) do + nil -> + attrs + |> Map.put(:site, site) + |> Map.put(:layout_id, default_layout.id) + |> Content.create_error_page!() + + _ -> + :skip + end + end + + :ok + end + defp load_site_from_db(site) do with :ok <- Beacon.RuntimeJS.load!(), :ok <- load_runtime_css(site), @@ -74,7 +131,8 @@ defmodule Beacon.Loader do :ok <- load_components(site), :ok <- load_snippet_helpers(site), :ok <- load_layouts(site), - :ok <- load_pages(site) do + :ok <- load_pages(site), + :ok <- load_error_pages(site) do :ok else _ -> raise Beacon.LoaderError, message: "failed to load resources for site #{site}" @@ -184,6 +242,12 @@ defmodule Beacon.Loader do :ok end + defp load_error_pages(site) do + error_pages = Content.list_error_pages(site, preloads: [:layout]) + ErrorPageModuleLoader.load_error_pages!(error_pages, site) + :ok + end + @doc false def stylesheet_module_for_site(site) do module_for_site(site, "Stylesheet") @@ -194,6 +258,11 @@ defmodule Beacon.Loader do module_for_site(site, "Component") end + @doc false + def error_module_for_site(site) do + module_for_site(site, "ErrorPages") + end + @doc false def snippet_helpers_module_for_site(site) do module_for_site(site, "SnippetHelpers") @@ -356,6 +425,15 @@ defmodule Beacon.Loader do def handle_info({:component_updated, component}, state) do :ok = load_components(component.site) :ok = Beacon.PubSub.component_loaded(component) + :ok = load_runtime_css(component.site) + {:noreply, state} + end + + @doc false + def handle_info({:error_page_updated, error_page}, state) do + :ok = load_error_pages(error_page.site) + :ok = Beacon.PubSub.error_page_loaded(error_page) + :ok = load_runtime_css(error_page.site) {:noreply, state} end diff --git a/lib/beacon/loader/error_page_module_loader.ex b/lib/beacon/loader/error_page_module_loader.ex new file mode 100644 index 00000000..009d4cde --- /dev/null +++ b/lib/beacon/loader/error_page_module_loader.ex @@ -0,0 +1,67 @@ +defmodule Beacon.Loader.ErrorPageModuleLoader do + @moduledoc false + alias Beacon.Content + alias Beacon.Content.ErrorPage + alias Beacon.Loader + + def load_error_pages!(error_pages, site) do + error_module = Loader.error_module_for_site(site) + layout_functions = Enum.map(error_pages, &build_layout_fn/1) + render_functions = Enum.map(error_pages, &build_render_fn(&1, error_module)) + + ast = + quote do + defmodule unquote(error_module) do + use Phoenix.HTML + require EEx + import Phoenix.Component + require Logger + + unquote_splicing(layout_functions) + unquote_splicing(render_functions) + + # catch-all for error which do not have an ErrorPage defined + def render(var!(conn), var!(status)) do + _ = var!(conn) + Logger.warning("missing error page for #{unquote(site)} status #{var!(status)}") + Plug.Conn.Status.reason_phrase(var!(status)) + end + + EEx.function_from_file( + :def, + :root_layout, + Path.join([:code.priv_dir(:beacon), "layouts", "runtime_error.html.heex"]), + [:assigns], + engine: Phoenix.HTML.Engine + ) + end + end + + :ok = Loader.reload_module!(error_module, ast) + {:ok, error_module, ast} + end + + defp build_layout_fn(%ErrorPage{} = error_page) do + %{site: site, layout: %{id: layout_id}, status: status} = error_page + %{template: template} = Content.get_published_layout(site, layout_id) + compiled = EEx.compile_string(template, engine: Phoenix.HTML.Engine) + + quote do + def layout(unquote(status), var!(assigns)) when is_map(var!(assigns)) do + unquote(compiled) + end + end + end + + defp build_render_fn(%ErrorPage{} = error_page, error_module) do + %{template: template, status: status} = error_page + compiled = EEx.compile_string(template, engine: Phoenix.HTML.Engine) + + quote do + def render(var!(conn), unquote(status)) do + var!(assigns) = %{conn: var!(conn), inner_content: unquote(error_module).layout(unquote(status), %{inner_content: unquote(compiled)})} + unquote(error_module).root_layout(var!(assigns)) + end + end + end +end diff --git a/lib/beacon/pub_sub.ex b/lib/beacon/pub_sub.ex index 32661ae3..1f1dca28 100644 --- a/lib/beacon/pub_sub.ex +++ b/lib/beacon/pub_sub.ex @@ -3,6 +3,8 @@ defmodule Beacon.PubSub do require Logger alias Beacon.Content.Component + alias Beacon.Content.ErrorPage + alias Beacon.Content.ErrorPage alias Beacon.Content.Layout alias Beacon.Content.Page @@ -128,6 +130,36 @@ defmodule Beacon.PubSub do defp component(component), do: %{site: component.site, id: component.id, name: component.name} + # Error Pages + + defp topic_error_pages(site), do: "beacon:#{site}:error_pages" + + defp topic_error_page(site, id) when is_binary(id) do + "beacon:#{site}:error_pages:#{id}" + end + + def subscribe_to_error_pages(site) do + Phoenix.PubSub.subscribe(@pubsub, topic_error_pages(site)) + end + + def subscribe_to_error_page(site, id) do + Phoenix.PubSub.subscribe(@pubsub, topic_error_page(site, id)) + end + + def error_page_updated(%ErrorPage{} = error_page) do + error_page.site + |> topic_error_pages() + |> broadcast({:error_page_updated, error_page(error_page)}) + end + + def error_page_loaded(error_page) do + error_page.site + |> topic_error_page(error_page.id) + |> local_broadcast({:error_page_loaded, error_page(error_page)}) + end + + defp error_page(error_page), do: %{site: error_page.site, id: error_page.id, status: error_page.status} + # Utils defp broadcast(topic, message) when is_binary(topic) do diff --git a/lib/beacon/tailwind_compiler.ex b/lib/beacon/tailwind_compiler.ex index 16057641..833e40cb 100644 --- a/lib/beacon/tailwind_compiler.ex +++ b/lib/beacon/tailwind_compiler.ex @@ -164,6 +164,13 @@ defmodule Beacon.TailwindCompiler do File.write!(page_path, page.template) page_path end) + end), + Task.async(fn -> + Enum.map(Content.list_error_pages(site, per_page: :infinity), fn error_page -> + error_page_path = Path.join(tmp_dir, "#{site}_error_page_#{error_page.status}.template") + File.write!(error_page_path, error_page.template) + error_page_path + end) end) ] |> Task.await_many() diff --git a/lib/beacon/template/heex.ex b/lib/beacon/template/heex.ex index b3e3e0f5..66aa6395 100644 --- a/lib/beacon/template/heex.ex +++ b/lib/beacon/template/heex.ex @@ -36,16 +36,19 @@ defmodule Beacon.Template.HEEx do @doc false def compile_heex_template!(file, template) do - EEx.compile_string(template, - engine: Phoenix.LiveView.TagEngine, - line: 1, - indentation: 0, - file: file, - caller: __ENV__, - source: template, - trim: true, - tag_handler: Phoenix.LiveView.HTMLEngine - ) + opts = + [ + engine: Phoenix.LiveView.TagEngine, + line: 1, + indentation: 0, + file: file, + caller: __ENV__, + source: template, + trim: true, + tag_handler: Phoenix.LiveView.HTMLEngine + ] + + EEx.compile_string(template, opts) end @doc """ diff --git a/lib/beacon/utils.ex b/lib/beacon/utils.ex new file mode 100644 index 00000000..b756e0ee --- /dev/null +++ b/lib/beacon/utils.ex @@ -0,0 +1,8 @@ +defmodule Beacon.Utils do + @moduledoc false + + # https://elixirforum.com/t/dynamically-generate-typespecs-from-module-attribute-list/7078/5 + def list_to_typespec(list) when is_list(list) do + Enum.reduce(list, &{:|, [], [&1, &2]}) + end +end diff --git a/lib/beacon_web/controllers/error_html.ex b/lib/beacon_web/controllers/error_html.ex index ad4d317b..0d84e3a5 100644 --- a/lib/beacon_web/controllers/error_html.ex +++ b/lib/beacon_web/controllers/error_html.ex @@ -2,20 +2,17 @@ defmodule BeaconWeb.ErrorHTML do @moduledoc false use BeaconWeb, :html + require Logger + alias Beacon.Loader - # If you want to customize your error pages, - # uncomment the embed_templates/1 call below - # and add pages to the error directory: - # - # * lib/beacon_web/controllers/error/404.html.heex - # * lib/beacon_web/controllers/error/500.html.heex - # - # embed_templates "error/*" - - # The default is to render a plain text page based on - # the template name. For example, "404.html" becomes - # "Not Found". - def render(template, _assigns) do - Phoenix.Controller.status_message_from_template(template) + def render(<> = template, %{conn: conn}) do + {_, _, %{extra: %{session: %{"beacon_site" => site}}}} = conn.private.phoenix_live_view + error_module = Loader.error_module_for_site(site) + conn = Plug.Conn.assign(conn, :__site__, site) + error_module.render(conn, String.to_integer(status_code)) + rescue + _ -> + Logger.warning("failed to render error page") + Phoenix.Controller.status_message_from_template(template) end end diff --git a/lib/mix/tasks/install.ex b/lib/mix/tasks/install.ex index eedf228f..772da950 100644 --- a/lib/mix/tasks/install.ex +++ b/lib/mix/tasks/install.ex @@ -34,6 +34,7 @@ defmodule Mix.Tasks.Beacon.Install do config_file_path = config_file_path("config.exs") maybe_inject_beacon_repo_into_ecto_repos(config_file_path) + inject_endpoint_render_errors_config(config_file_path) dev_config_file = config_file_path("dev.exs") maybe_inject_beacon_repo_config(dev_config_file, bindings) @@ -91,6 +92,37 @@ defmodule Mix.Tasks.Beacon.Install do end end + @doc false + def inject_endpoint_render_errors_config(config_file_path) do + config_file_content = File.read!(config_file_path) + + if String.contains?(config_file_content, "BeaconWeb.ErrorHTML") do + Mix.shell().info([ + :yellow, + "* skip ", + :reset, + "injecting Beacon.ErrorHTML to render_errors into ", + Path.relative_to_cwd(config_file_path), + " (already exists)" + ]) + else + regex = ~r/(config.*\.Endpoint,\n)((?:.+\n)*\s*)\n/ + render_errors_value = [formats: [html: BeaconWeb.ErrorHTML]] + + [_header, endpoint_config_str] = Regex.run(regex, config_file_content, capture: :all_but_first) + {config_list, []} = Code.eval_string("[" <> endpoint_config_str <> "]") + updated_config_list = Keyword.update(config_list, :render_errors, render_errors_value, fn _ -> render_errors_value end) + updated_str = inspect(updated_config_list) <> "\n" + + new_config_file_content = + regex + |> Regex.replace(config_file_content, "\\1#{updated_str}") + |> Code.format_string!(file: config_file_path) + + File.write!(config_file_path, [new_config_file_content, "\n"]) + end + end + @doc false def maybe_inject_beacon_repo_config(config_file_path, bindings) do config_file_content = File.read!(config_file_path) diff --git a/mix.exs b/mix.exs index 1f3c700a..d950ad46 100644 --- a/mix.exs +++ b/mix.exs @@ -104,6 +104,7 @@ defmodule Beacon.MixProject do Content: [ Beacon.Content, Beacon.Content.Component, + Beacon.Content.ErrorPage, Beacon.Content.Layout, Beacon.Content.LayoutEvent, Beacon.Content.LayoutSnapshot, @@ -181,7 +182,8 @@ defmodule Beacon.MixProject do "Functions: Stylesheets": &(&1[:type] == :stylesheets), "Functions: Components": &(&1[:type] == :components), "Functions: Snippets": &(&1[:type] == :snippets), - "Functions: Page Event Handlers": &(&1[:type] == :page_event_handlers) + "Functions: Page Event Handlers": &(&1[:type] == :page_event_handlers), + "Functions: Error Pages": &(&1[:type] == :error_pages) ] ] end diff --git a/priv/layouts/runtime_error.html.heex b/priv/layouts/runtime_error.html.heex new file mode 100644 index 00000000..2d527f5f --- /dev/null +++ b/priv/layouts/runtime_error.html.heex @@ -0,0 +1,13 @@ + + + + /> + Error + /> + + + + <%= @inner_content %> + + diff --git a/priv/repo/migrations/20230816195326_create_error_pages.exs b/priv/repo/migrations/20230816195326_create_error_pages.exs new file mode 100644 index 00000000..ad28ac03 --- /dev/null +++ b/priv/repo/migrations/20230816195326_create_error_pages.exs @@ -0,0 +1,17 @@ +defmodule Beacon.Repo.Migrations.CreateErrorPages do + use Ecto.Migration + + def change do + create table(:beacon_error_pages, primary_key: false) do + add :id, :binary_id, primary_key: true + add :site, :text, null: false + add :status, :integer, null: false + add :template, :text, null: false + add :layout_id, references(:beacon_layouts, type: :binary_id) + + timestamps() + end + + create unique_index(:beacon_error_pages, [:status, :site]) + end +end diff --git a/test/beacon/content_test.exs b/test/beacon/content_test.exs index a5487275..f5fe581e 100644 --- a/test/beacon/content_test.exs +++ b/test/beacon/content_test.exs @@ -4,6 +4,8 @@ defmodule Beacon.ContentTest do import Beacon.Fixtures alias Beacon.Content + alias Beacon.Content.Component + alias Beacon.Content.ErrorPage alias Beacon.Content.Layout alias Beacon.Content.LayoutEvent alias Beacon.Content.LayoutSnapshot @@ -13,6 +15,7 @@ defmodule Beacon.ContentTest do alias Beacon.Content.PageSnapshot alias Beacon.Content.PageVariant alias Beacon.Repo + alias Ecto.Changeset describe "layouts" do test "create layout should create a created event" do @@ -434,6 +437,41 @@ defmodule Beacon.ContentTest do end end + describe "error_pages:" do + test "get_error_page/2" do + error_page = error_page_fixture(%{site: :my_site, status: 404}) + _other = error_page_fixture(%{site: :my_site, status: 400}) + + assert ^error_page = Content.get_error_page(:my_site, 404) + end + + test "create_error_page/1 OK" do + %{id: layout_id} = layout_fixture() + attrs = %{site: :my_site, status: 400, template: "Oops!", layout_id: layout_id} + + assert {:ok, %ErrorPage{} = error_page} = Content.create_error_page(attrs) + assert %{site: :my_site, status: 400, template: "Oops!", layout_id: ^layout_id} = error_page + end + + test "create_error_page/1 ERROR (duplicate)" do + error_page = error_page_fixture() + bad_attrs = %{site: error_page.site, status: error_page.status, template: "Error", layout_id: layout_fixture().id} + + assert {:error, %Changeset{errors: errors}} = Content.create_error_page(bad_attrs) + assert [{:status, {"has already been taken", [constraint: :unique, constraint_name: "beacon_error_pages_status_site_index"]}}] = errors + end + + test "update_error_page/2" do + error_page = error_page_fixture() + assert {:ok, %ErrorPage{template: "Changed"}} = Content.update_error_page(error_page, %{template: "Changed"}) + end + + test "delete_error_page/1" do + error_page = error_page_fixture() + assert {:ok, %ErrorPage{__meta__: %{state: :deleted}}} = Content.delete_error_page(error_page) + end + end + describe "components" do test "validate template heex on create" do assert {:error, %Ecto.Changeset{errors: [body: {"invalid", [compilation_error: compilation_error]}]}} = @@ -460,5 +498,10 @@ defmodule Beacon.ContentTest do assert Enum.member?(components, component_b) refute Enum.member?(components, component_a) end + + test "update_component" do + component = component_fixture(name: "new_component", body: "old_body") + assert {:ok, %Component{body: "new_body"}} = Content.update_component(component, %{body: "new_body"}) + end end end diff --git a/test/beacon/loader/error_page_module_loader_test.exs b/test/beacon/loader/error_page_module_loader_test.exs new file mode 100644 index 00000000..d64c8d14 --- /dev/null +++ b/test/beacon/loader/error_page_module_loader_test.exs @@ -0,0 +1,143 @@ +defmodule Beacon.Loader.ErrorPageModuleLoaderTest do + use BeaconWeb.ConnCase, async: false + import Beacon.Fixtures + alias Beacon.Loader.ErrorPageModuleLoader + + @site :my_site + + defp load_error_pages_module(site) do + {:ok, module, _ast} = + site + |> Beacon.Content.list_error_pages(preloads: [:layout]) + |> ErrorPageModuleLoader.load_error_pages!(site) + + module + end + + def build_conn(conn) do + conn + |> Plug.Conn.assign(:__site__, @site) + |> Plug.Conn.put_private(:phoenix_router, Beacon.BeaconTest.Router) + end + + setup_all do + start_supervised!({Beacon.Loader, Beacon.Config.fetch!(@site)}) + :ok + end + + setup %{conn: conn} do + :ok = Beacon.Loader.populate_layouts(@site) + :ok = Beacon.Loader.populate_error_pages(@site) + error_module = load_error_pages_module(@site) + + [conn: build_conn(conn), error_module: error_module] + end + + test "root layout", %{conn: conn, error_module: error_module} do + expected = + ~S""" + + + + + Error + + + + + #inner_content# + + + """ + |> Regex.compile!() + + {:safe, html} = error_module.root_layout(%{conn: conn, inner_content: "#inner_content#"}) + assert IO.iodata_to_binary(html) =~ expected + end + + test "default layouts", %{error_module: error_module} do + assert error_module.layout(404, %{inner_content: "Not Found"}) == {:safe, ["Not Found"]} + assert error_module.layout(500, %{inner_content: "Internal Server Error"}) == {:safe, ["Internal Server Error"]} + end + + test "custom layout" do + layout = published_layout_fixture(template: "#custom_layout#<%= @inner_content %>", site: @site) + error_page = error_page_fixture(layout: layout, template: "error_501", status: 501, site: @site) + error_module = load_error_pages_module(@site) + + assert error_module.layout(501, %{inner_content: error_page.template}) == {:safe, ["#custom_layout#", "error_501"]} + end + + test "default error pages", %{conn: conn, error_module: error_module} do + expected = + ~S""" + + + + + Error + + + + + Not Found + + + """ + |> Regex.compile!() + + {:safe, html} = error_module.render(conn, 404) + assert IO.iodata_to_binary(html) =~ expected + + expected = + ~S""" + + + + + Error + + + + + Internal Server Error + + + """ + |> Regex.compile!() + + {:safe, html} = error_module.render(conn, 500) + assert IO.iodata_to_binary(html) =~ expected + end + + test "custom error page", %{conn: conn} do + layout = published_layout_fixture(template: "#custom_layout#<%= @inner_content %>", site: @site) + _error_page = error_page_fixture(layout: layout, template: ~s|error_501|, status: 501, site: @site) + error_module = load_error_pages_module(@site) + + expected = + ~S""" + + + + + Error + + + + + #custom_layout#error_501 + + + """ + |> Regex.compile!() + + {:safe, html} = error_module.render(conn, 501) + + assert IO.iodata_to_binary(html) =~ expected + end +end diff --git a/test/beacon_web/controllers/error_html_test.exs b/test/beacon_web/controllers/error_html_test.exs new file mode 100644 index 00000000..60b226b7 --- /dev/null +++ b/test/beacon_web/controllers/error_html_test.exs @@ -0,0 +1,13 @@ +defmodule BeaconWeb.ErrorHTMLTest do + use ExUnit.Case, async: true + + alias BeaconWeb.ErrorHTML + + test "invalid status code" do + assert ErrorHTML.render("invalid", %{conn: nil}) == "Internal Server Error" + end + + test "invalid conn" do + assert ErrorHTML.render("404.html", %{conn: nil}) == "Not Found" + end +end diff --git a/test/beacon_web/live/page_live_test.exs b/test/beacon_web/live/page_live_test.exs index 967723b6..c531c002 100644 --- a/test/beacon_web/live/page_live_test.exs +++ b/test/beacon_web/live/page_live_test.exs @@ -140,8 +140,7 @@ defmodule BeaconWeb.Live.PageLiveTest do test "update resource links on layout publish", %{conn: conn, layout: layout} do Beacon.PubSub.subscribe_to_layout(layout.site, layout.id) - {:ok, layout} = - Content.update_layout(layout, %{"resource_links" => [%{"rel" => "stylesheet", "href" => "color.css"}]}) + {:ok, layout} = Content.update_layout(layout, %{"resource_links" => [%{"rel" => "stylesheet", "href" => "color.css"}]}) id = layout.id {:ok, _layout} = Content.publish_layout(layout) diff --git a/test/mix/tasks/install_test.exs b/test/mix/tasks/install_test.exs index a69ce876..7495a9a6 100644 --- a/test/mix/tasks/install_test.exs +++ b/test/mix/tasks/install_test.exs @@ -27,6 +27,7 @@ defmodule Mix.Tasks.Beacon.InstallTest do Install.run(["--site", "my_site"]) # Injects beacon repo config into config file + # and sets Endpoint's :render_errors option assert File.read!(@config_path) == """ # This file is responsible for configuring your application @@ -44,10 +45,7 @@ defmodule Mix.Tasks.Beacon.InstallTest do # Configures the endpoint config :my_app, MyAppWeb.Endpoint, url: [host: "localhost"], - render_errors: [ - formats: [html: MyAppWeb.ErrorHTML, json: MyAppWeb.ErrorJSON], - layout: false - ], + render_errors: [formats: [html: BeaconWeb.ErrorHTML]], pubsub_server: MyApp.PubSub, live_view: [signing_salt: "Ozb0CE3q"] diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 6db08edb..8b4fd8cb 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -1,5 +1,6 @@ defmodule Beacon.Fixtures do alias Beacon.Content + alias Beacon.Content.ErrorPage alias Beacon.Content.PageEventHandler alias Beacon.Content.PageVariant alias Beacon.MediaLibrary @@ -185,4 +186,17 @@ defmodule Beacon.Fixtures do |> PageEventHandler.changeset(full_attrs) |> Repo.insert!() end + + def error_page_fixture(attrs \\ %{}) do + layout = get_lazy(attrs, :layout, fn -> layout_fixture() end) + + attrs + |> Enum.into(%{ + site: "my_site", + status: Enum.random(ErrorPage.valid_statuses()), + template: "Uh-oh!", + layout_id: layout.id + }) + |> Content.create_error_page!() + end end