defmodule AshAuthentication.InfoGenerator do @moduledoc """ Used to dynamically generate configuration functions for Spark extensions based on their DSL. ## Usage ```elixir defmodule MyConfig do use AshAuthentication.InfoGenerator, extension: MyDslExtension, sections: [:my_section] end ``` """ @type options :: [{:extension, module} | {:sections, [atom]}] @doc false @spec __using__(options) :: Macro.t() defmacro __using__(opts) do extension = Keyword.fetch!(opts, :extension) |> Macro.expand(__CALLER__) sections = Keyword.get(opts, :sections, []) quote do require AshAuthentication.InfoGenerator require unquote(extension) AshAuthentication.InfoGenerator.generate_config_functions( unquote(extension), unquote(sections) ) AshAuthentication.InfoGenerator.generate_options_functions( unquote(extension), unquote(sections) ) AshAuthentication.InfoGenerator.generate_entity_functions( unquote(extension), unquote(sections) ) end end @doc """ Given an extension and a list of DSL sections, generate an options function which returns a map of all configured options for a resource (including defaults). """ @spec generate_options_functions(module, [atom]) :: Macro.t() defmacro generate_options_functions(extension, sections) do for {path, options} <- extension_sections_to_option_list(extension, sections) do function_name = :"#{Enum.join(path, "_")}_options" quote location: :keep do @doc """ #{unquote(Enum.join(path, "."))} DSL options Returns a map containing the and any configured or default values. """ @spec unquote(function_name)(dsl_or_resource :: module | map) :: %{required(atom) => any} def unquote(function_name)(dsl_or_resource) do import Spark.Dsl.Extension, only: [get_opt: 4] unquote(Macro.escape(options)) |> Stream.map(fn option -> value = dsl_or_resource |> get_opt(option.path, option.name, Map.get(option, :default)) {option.name, value} end) |> Stream.reject(&is_nil(elem(&1, 1))) |> Map.new() end end end end @doc """ Given an extension and a list of DSL sections, generate an entities function which returns a list of entities. """ @spec generate_entity_functions(module, [atom]) :: Macro.t() defmacro generate_entity_functions(extension, sections) do entity_paths = extension.sections() |> Stream.filter(&(&1.name in sections)) |> Stream.flat_map(&explode_section([], &1)) |> Stream.filter(fn {_, section} -> Enum.any?(section.entities) end) |> Stream.map(&elem(&1, 0)) for path <- entity_paths do function_name = path |> Enum.join("_") |> String.to_atom() quote location: :keep do @doc """ #{unquote(Enum.join(path, "."))} DSL entities """ @spec unquote(function_name)(dsl_or_resource :: module | map) :: [struct] def unquote(function_name)(dsl_or_resource) do import Spark.Dsl.Extension, only: [get_entities: 2] get_entities(dsl_or_resource, unquote(path)) end end end end @doc """ Given an extension and a list of DSL sections generate individual config functions for each option. """ @spec generate_config_functions(module, [atom]) :: Macro.t() defmacro generate_config_functions(extension, sections) do for {_, options} <- extension_sections_to_option_list(extension, sections) do for option <- options do generate_config_function(option) end end end defp explode_section(path, %{sections: [], name: name} = section), do: [{path ++ [name], section}] defp explode_section(path, %{sections: sections, name: name} = section) do path = path ++ [name] head = [{path, section}] tail = Stream.flat_map(sections, &explode_section(path, &1)) Stream.concat(head, tail) end defp extension_sections_to_option_list(extension, sections) do extension.sections() |> Stream.filter(&(&1.name in sections)) |> Stream.flat_map(&explode_section([], &1)) |> Stream.reject(fn {_, section} -> Enum.empty?(section.schema) end) |> Stream.map(fn {path, section} -> schema = section.schema |> Enum.map(fn {name, opts} -> opts |> Map.new() |> Map.take(~w[type doc default]a) |> Map.update!(:type, &spec_for_type/1) |> Map.put(:pred?, name |> to_string() |> String.ends_with?("?")) |> Map.put(:name, name) |> Map.put(:path, path) |> Map.put( :function_name, path |> Enum.concat([name]) |> Enum.join("_") |> String.trim_trailing("?") |> String.to_atom() ) end) {path, schema} end) |> Map.new() end defp generate_config_function(%{pred?: true} = option) do function_name = :"#{option.function_name}?" quote location: :keep do @doc unquote(option.doc) @spec unquote(function_name)(dsl_or_resource :: module | map) :: unquote(option.type) def unquote(function_name)(dsl_or_resource) do import Spark.Dsl.Extension, only: [get_opt: 4] get_opt( dsl_or_resource, unquote(option.path), unquote(option.name), unquote(option.default) ) end end end defp generate_config_function(option) do quote location: :keep do @doc unquote(Map.get(option, :doc, false)) @spec unquote(option.function_name)(dsl_or_resource :: module | map) :: {:ok, unquote(option.type)} | :error def unquote(option.function_name)(dsl_or_resource) do import Spark.Dsl.Extension, only: [get_opt: 4] case get_opt( dsl_or_resource, unquote(option.path), unquote(option.name), unquote(Map.get(option, :default, :error)) ) do :error -> :error value -> {:ok, value} end end @doc unquote(Map.get(option, :doc, false)) @spec unquote(:"#{option.function_name}!")(dsl_or_resource :: module | map) :: unquote(option.type) | no_return def unquote(:"#{option.function_name}!")(dsl_or_resource) do import Spark.Dsl.Extension, only: [get_opt: 4] case get_opt( dsl_or_resource, unquote(option.path), unquote(option.name), unquote(Map.get(option, :default, :error)) ) do :error -> raise "No configuration for `#{unquote(option.name)}` present on `#{inspect(dsl_or_resource)}`." value -> value end end end end defp spec_for_type({:behaviour, _module}), do: {:module, [], Elixir} defp spec_for_type({:spark_function_behaviour, behaviour, {_, arity}}), do: spec_for_type( {:or, [ {:behaviour, behaviour}, {{:behaviour, behaviour}, {:keyword, [], Elixir}}, {:fun, arity} ]} ) defp spec_for_type({:fun, arity}) do args = 0..(arity - 1) |> Enum.map(fn _ -> {:any, [], Elixir} end) [{:->, [], [args, {:any, [], Elixir}]}] end defp spec_for_type({:or, [type]}), do: spec_for_type(type) defp spec_for_type({:or, [next | remaining]}), do: {:|, [], [spec_for_type(next), spec_for_type({:or, remaining})]} defp spec_for_type({:in, %Range{first: first, last: last}}) when is_integer(first) and is_integer(last), do: {:.., [], [first, last]} defp spec_for_type({:in, %Range{first: first, last: last}}), do: {{:., [], [{:__aliases__, [], [:Range]}, :t]}, [], [spec_for_type(first), spec_for_type(last)]} defp spec_for_type({:in, [type]}), do: spec_for_type(type) defp spec_for_type({:in, [next | remaining]}), do: {:|, [], [spec_for_type(next), spec_for_type({:in, remaining})]} defp spec_for_type({:list, subtype}), do: [spec_for_type(subtype)] defp spec_for_type({:custom, _, _, _}), do: spec_for_type(:any) defp spec_for_type({:tuple, subtypes}) do subtypes |> Enum.map(&spec_for_type/1) |> List.to_tuple() end defp spec_for_type(:string), do: {{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []} defp spec_for_type(terminal) when terminal in ~w[any map atom string boolean integer non_neg_integer pos_integer float timeout pid reference mfa]a, do: {terminal, [], Elixir} defp spec_for_type(atom) when is_atom(atom), do: atom defp spec_for_type(number) when is_number(number), do: number defp spec_for_type(string) when is_binary(string), do: spec_for_type(:string) defp spec_for_type({mod, arg}) when is_atom(mod) and is_list(arg), do: {{:module, [], Elixir}, {:list, [], Elixir}} defp spec_for_type(tuple) when is_tuple(tuple), do: tuple |> Tuple.to_list() |> Enum.map(&spec_for_type/1) |> List.to_tuple() defp spec_for_type([]), do: [] defp spec_for_type([type]), do: [spec_for_type(type)] end