From d9817a6c95cfb8a3fcfb74a9a5f5b482ddab9d0c Mon Sep 17 00:00:00 2001 From: T Floyd Wright Date: Fri, 15 May 2026 11:34:26 -0800 Subject: [PATCH] fix: require compile-time global resource config Global resource config (everything in `base_configs_schema/0` except `ecto_repo`) is now read at compile time at the application level, so misconfigurations surface at build time and the value used by the running app cannot diverge from what was validated. Previously only `create_with` and `update_with` were compile-time for the conflict check; everything else fell back to `Application.get_env` at runtime. A boot check raises if any of these keys differs between compile and runtime env, pointing the user to `config/config.exs`. --- README.md | 7 +++- README.md.eex | 7 +++- lib/application.ex | 55 ++++++++++++++++++++++++++++ lib/live_admin.ex | 2 + lib/live_admin/components/nav.ex | 2 +- lib/live_admin/router.ex | 54 ++++++++++++++++----------- test/live_admin/application_test.exs | 24 ++++++++++++ test/live_admin/router_test.exs | 54 +++++++++++++++++++++++++++ 8 files changed, 181 insertions(+), 24 deletions(-) create mode 100644 test/live_admin/application_test.exs diff --git a/README.md b/README.md index 273043e..ea7a842 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,13 @@ Extra options: App config can be used to set a global default to apply to all resources unless overridden in their individual config, or the LiveAdmin instance. -Extra options: +When set at the application level, resource-level keys are read at **compile time**, and must be configured in `config/config.exs` (or another compile-time config file). Setting any of them in `config/runtime.exs` (or otherwise at runtime) will raise on boot. + +Compile-time keys: `components`, `query_with`, `render_with`, `delete_with`, `create_with`, `update_with`, `validate_with`, `label_with`, `title_with`, `hidden_fields`, `immutable_fields`, `actions`, `tasks` + +Runtime keys: +* `ecto_repo` - module used to execute queries (Required) * `session_store` - a module implementing the `LiveAdmin.Session.Store` behavior, used to persist session data (default: LiveAdmin.Session.Agent) * `css_overrides` - a binary or MFA identifying a function that returns CSS to be appended to app css * `gettext_backend` - a module implementing the [Gettext API](https://hexdocs.pm/gettext/Gettext.html#module-gettext-api) that will be used for translations diff --git a/README.md.eex b/README.md.eex index 273043e..ea7a842 100644 --- a/README.md.eex +++ b/README.md.eex @@ -117,8 +117,13 @@ Extra options: App config can be used to set a global default to apply to all resources unless overridden in their individual config, or the LiveAdmin instance. -Extra options: +When set at the application level, resource-level keys are read at **compile time**, and must be configured in `config/config.exs` (or another compile-time config file). Setting any of them in `config/runtime.exs` (or otherwise at runtime) will raise on boot. + +Compile-time keys: `components`, `query_with`, `render_with`, `delete_with`, `create_with`, `update_with`, `validate_with`, `label_with`, `title_with`, `hidden_fields`, `immutable_fields`, `actions`, `tasks` + +Runtime keys: +* `ecto_repo` - module used to execute queries (Required) * `session_store` - a module implementing the `LiveAdmin.Session.Store` behavior, used to persist session data (default: LiveAdmin.Session.Agent) * `css_overrides` - a binary or MFA identifying a function that returns CSS to be appended to app css * `gettext_backend` - a module implementing the [Gettext API](https://hexdocs.pm/gettext/Gettext.html#module-gettext-api) that will be used for translations diff --git a/lib/application.ex b/lib/application.ex index b1c55dc..a7faef4 100644 --- a/lib/application.ex +++ b/lib/application.ex @@ -1,6 +1,38 @@ defmodule LiveAdmin.Application do use Application + @compile_time_app_keys [ + :components, + :query_with, + :render_with, + :delete_with, + :create_with, + :update_with, + :validate_with, + :label_with, + :title_with, + :hidden_fields, + :immutable_fields, + :actions, + :tasks + ] + + @compile_time_app_config %{ + components: Application.compile_env(:live_admin, :components), + query_with: Application.compile_env(:live_admin, :query_with), + render_with: Application.compile_env(:live_admin, :render_with), + delete_with: Application.compile_env(:live_admin, :delete_with), + create_with: Application.compile_env(:live_admin, :create_with), + update_with: Application.compile_env(:live_admin, :update_with), + validate_with: Application.compile_env(:live_admin, :validate_with), + label_with: Application.compile_env(:live_admin, :label_with), + title_with: Application.compile_env(:live_admin, :title_with), + hidden_fields: Application.compile_env(:live_admin, :hidden_fields), + immutable_fields: Application.compile_env(:live_admin, :immutable_fields), + actions: Application.compile_env(:live_admin, :actions), + tasks: Application.compile_env(:live_admin, :tasks) + } + def start(_type, _args) do opts = [strategy: :one_for_one, name: LiveAdmin.Supervisor] @@ -14,9 +46,32 @@ defmodule LiveAdmin.Application do NimbleOptions.validate!(Application.get_all_env(:live_admin), global_options_schema) + validate_compile_time_config!() + Supervisor.start_link(children(), opts) end + @doc false + def validate_compile_time_config! do + @compile_time_app_keys + |> Enum.filter(fn key -> + Map.fetch!(@compile_time_app_config, key) != Application.get_env(:live_admin, key) + end) + |> case do + [] -> + :ok + + mismatches -> + raise """ + The following :live_admin config keys have been set at runtime, but they must be set at compile time: + + #{Enum.map_join(mismatches, "\n", &" * #{inspect(&1)}")} + + Move these into config/config.exs (or another compile-time config file). + """ + end + end + defp children do [ {LiveAdmin.Session.Agent, %{}}, diff --git a/lib/live_admin.ex b/lib/live_admin.ex index 900cec3..9215220 100644 --- a/lib/live_admin.ex +++ b/lib/live_admin.ex @@ -121,6 +121,8 @@ defmodule LiveAdmin do Used internally to validate configuration in apps using LiveAdmin. + When set at the application level, every option in this schema *except* `ecto_repo` is read at compile time. Configure them in `config/config.exs` (or another compile-time config file); setting them at runtime will raise on boot. + Supported options: #{@options_schema |> NimbleOptions.new!() |> NimbleOptions.docs()} """ diff --git a/lib/live_admin/components/nav.ex b/lib/live_admin/components/nav.ex index 93f96be..b4bfea9 100644 --- a/lib/live_admin/components/nav.ex +++ b/lib/live_admin/components/nav.ex @@ -16,7 +16,7 @@ defmodule LiveAdmin.Components.Nav do match?( %{ metadata: %{ - phoenix_live_view: {_, _, _, %{extra: %{session: {_, _, [^base_path, _]}}}} + phoenix_live_view: {_, _, _, %{extra: %{session: {_, _, [^base_path, _, _]}}}} } }, r diff --git a/lib/live_admin/router.ex b/lib/live_admin/router.ex index 03ef5b5..1ad834f 100644 --- a/lib/live_admin/router.ex +++ b/lib/live_admin/router.ex @@ -23,10 +23,27 @@ defmodule LiveAdmin.Router do @base_path Path.join(["/", current_path, unquote(path)]) @__live_admin_scope_opts__ unquote(opts) + @__live_admin_app_config__ [ + components: Application.compile_env(:live_admin, :components, []), + query_with: Application.compile_env(:live_admin, :query_with), + render_with: Application.compile_env(:live_admin, :render_with), + delete_with: Application.compile_env(:live_admin, :delete_with), + create_with: Application.compile_env(:live_admin, :create_with), + update_with: Application.compile_env(:live_admin, :update_with), + validate_with: Application.compile_env(:live_admin, :validate_with), + label_with: Application.compile_env(:live_admin, :label_with), + title_with: Application.compile_env(:live_admin, :title_with), + hidden_fields: Application.compile_env(:live_admin, :hidden_fields, []), + immutable_fields: Application.compile_env(:live_admin, :immutable_fields, []), + actions: Application.compile_env(:live_admin, :actions, []), + tasks: Application.compile_env(:live_admin, :tasks, []) + ] scope unquote(path), alias: false, as: false do live_session :"live_admin_#{@base_path}", - session: {unquote(__MODULE__), :build_session, [@base_path, unquote(opts)]}, + session: + {unquote(__MODULE__), :build_session, + [@base_path, unquote(opts), @__live_admin_app_config__]}, root_layout: {LiveAdmin.View, :layout}, layout: {LiveAdmin.View, :app}, on_mount: {unquote(__MODULE__), :assign_options} do @@ -58,9 +75,7 @@ defmodule LiveAdmin.Router do LiveAdmin.Router.__validate_config__!( resource_mod, @__live_admin_scope_opts__, - create_with: Application.compile_env(:live_admin, :create_with), - update_with: Application.compile_env(:live_admin, :update_with), - components: Application.compile_env(:live_admin, :components, []) + @__live_admin_app_config__ ) full_path = Path.join(@base_path, path) @@ -112,7 +127,7 @@ defmodule LiveAdmin.Router do end) end - def build_session(conn, base_path, opts) do + def build_session(conn, base_path, opts, app_config) do opts_schema = LiveAdmin.base_configs_schema() ++ [title: [type: :string, default: "LiveAdmin"], on_mount: [type: {:tuple, [:atom, :atom]}]] @@ -128,7 +143,7 @@ defmodule LiveAdmin.Router do index: LiveAdmin.Components.Container.Index, show: LiveAdmin.Components.Container.Show ], - Application.get_env(:live_admin, :components, []) + Keyword.get(app_config, :components, []) ) opts = @@ -139,21 +154,18 @@ defmodule LiveAdmin.Router do Keyword.merge(default_components, Keyword.get(opts, :components, [])) ) |> Keyword.put_new(:ecto_repo, Application.get_env(:live_admin, :ecto_repo)) - |> Keyword.put_new(:render_with, Application.get_env(:live_admin, :render_with)) - |> Keyword.put_new(:delete_with, Application.get_env(:live_admin, :delete_with)) - |> Keyword.put_new(:create_with, Application.get_env(:live_admin, :create_with)) - |> Keyword.put_new(:query_with, Application.get_env(:live_admin, :query_with)) - |> Keyword.put_new(:update_with, Application.get_env(:live_admin, :update_with)) - |> Keyword.put_new(:label_with, Application.get_env(:live_admin, :label_with)) - |> Keyword.put_new(:title_with, Application.get_env(:live_admin, :title_with)) - |> Keyword.put_new(:validate_with, Application.get_env(:live_admin, :validate_with)) - |> Keyword.put_new(:hidden_fields, Application.get_env(:live_admin, :hidden_fields, [])) - |> Keyword.put_new( - :immutable_fields, - Application.get_env(:live_admin, :immutable_fields, []) - ) - |> Keyword.put_new(:actions, Application.get_env(:live_admin, :actions, [])) - |> Keyword.put_new(:tasks, Application.get_env(:live_admin, :tasks, [])) + |> Keyword.put_new(:render_with, Keyword.get(app_config, :render_with)) + |> Keyword.put_new(:delete_with, Keyword.get(app_config, :delete_with)) + |> Keyword.put_new(:create_with, Keyword.get(app_config, :create_with)) + |> Keyword.put_new(:query_with, Keyword.get(app_config, :query_with)) + |> Keyword.put_new(:update_with, Keyword.get(app_config, :update_with)) + |> Keyword.put_new(:label_with, Keyword.get(app_config, :label_with)) + |> Keyword.put_new(:title_with, Keyword.get(app_config, :title_with)) + |> Keyword.put_new(:validate_with, Keyword.get(app_config, :validate_with)) + |> Keyword.put_new(:hidden_fields, Keyword.get(app_config, :hidden_fields, [])) + |> Keyword.put_new(:immutable_fields, Keyword.get(app_config, :immutable_fields, [])) + |> Keyword.put_new(:actions, Keyword.get(app_config, :actions, [])) + |> Keyword.put_new(:tasks, Keyword.get(app_config, :tasks, [])) %{ "session_id" => LiveAdmin.session_store().init!(conn), diff --git a/test/live_admin/application_test.exs b/test/live_admin/application_test.exs new file mode 100644 index 0000000..b8f80a1 --- /dev/null +++ b/test/live_admin/application_test.exs @@ -0,0 +1,24 @@ +defmodule LiveAdmin.ApplicationTest do + use ExUnit.Case, async: false + + describe "validate_compile_time_config! when a resource option is set at runtime" do + setup do + Application.put_env(:live_admin, :create_with, {RuntimeMod, :runtime_fun}) + on_exit(fn -> Application.delete_env(:live_admin, :create_with) end) + + :ok + end + + test "raises pointing the user to compile-time config" do + assert_raise RuntimeError, ~r/create_with/, fn -> + LiveAdmin.Application.validate_compile_time_config!() + end + end + end + + describe "validate_compile_time_config! when no resource options diverge between compile and runtime env" do + test "returns :ok" do + assert :ok == LiveAdmin.Application.validate_compile_time_config!() + end + end +end diff --git a/test/live_admin/router_test.exs b/test/live_admin/router_test.exs index ecb5045..4222403 100644 --- a/test/live_admin/router_test.exs +++ b/test/live_admin/router_test.exs @@ -1,8 +1,62 @@ defmodule LiveAdmin.RouterTest do use ExUnit.Case, async: true + import Mox + alias LiveAdmin.Router + setup :verify_on_exit! + + describe "build_session/4 with a compile-time resource option in app_config" do + setup do + stub_with(LiveAdminTest.MockSession, LiveAdminTest.StubSession) + + session = + Router.build_session( + %Plug.Conn{}, + "/admin", + [], + create_with: {SomeMod, :some_fun} + ) + + %{session: session} + end + + test "resolves the option from app_config", %{session: session} do + assert session["opts"][:create_with] == {SomeMod, :some_fun} + end + end + + describe "build_session/4 when a compile-time resource option is set in runtime Application env" do + setup do + stub_with(LiveAdminTest.MockSession, LiveAdminTest.StubSession) + + Application.put_env(:live_admin, :create_with, {RuntimeMod, :runtime_fun}) + on_exit(fn -> Application.delete_env(:live_admin, :create_with) end) + + session = Router.build_session(%Plug.Conn{}, "/admin", [], []) + + %{session: session} + end + + test "ignores the runtime value", %{session: session} do + refute session["opts"][:create_with] + end + end + + describe "build_session/4 list-typed resource options not provided in app_config" do + setup do + stub_with(LiveAdminTest.MockSession, LiveAdminTest.StubSession) + + session = Router.build_session(%Plug.Conn{}, "/admin", [], []) + %{session: session} + end + + test "default to an empty list", %{session: session} do + assert session["opts"][:hidden_fields] == [] + end + end + describe "create_with: false and custom :create component at the resource level" do test "raises ArgumentError" do assert_raise ArgumentError, ~r/create_with: false.*:create component/, fn ->