defmodule Dagger.Mod.Object do @moduledoc """ Declare a module as an object type. ## Declare an object Add `use Dagger.Mod.Object` to the Elixir module that want to be a Dagger module and give a name through `:name` configuration: defmodule Potato do use Dagger.Mod.Object, name: "Potato" # ... end The module also support documentation by using Elixir standard documentation, `@moduledoc`. ## Declare a function The module provides a `defn`, a macro for declare a function. Let's declare a new function named `echo` that accepts a `name` as a string and return a container that echo a name in the module `Potato` from the previous section: defmodule Potato do use Dagger.Mod.Object, name: "Potato" defn echo(name: String.t()) :: Dagger.Container.t() do dag() |> Dagger.Client.container() |> Dagger.Container.from("alpine") |> Dagger.Container.with_exec(["echo", name]) end end From the example above, the `defn` allows you to annotate a type to function arguments and return type by using Elixir Typespec. The type will convert to a Dagger type when registering a module. The supported primitive types for now are: 1. `integer()` for a boolean type. 2. `boolean()` for a boolean type. 3. `String.t()` or `binary()` for a string type. 4. `list(type)` or `[type]` for a list type. 5. `type | nil` for optional type. 6. Any type that generated under `Dagger` namespace (`Dagger.Container.t()`, `Dagger.Directory.t()`, etc.). The function also support documentation by using Elixir standard documentation, `@doc`. """ @type function_name() :: atom() @type function_def() :: {function_name(), keyword()} alias Dagger.Mod.Object.Defn alias Dagger.Mod.Object.Meta @doc """ Get module documentation. Returns module doc string or `nil` if the given module didn't have a documentation. """ @spec get_module_doc(module()) :: String.t() | nil def get_module_doc(module) do with {module_doc, _} <- fetch_docs(module), %{"en" => doc} <- module_doc do String.trim(doc) else :none -> nil :hidden -> nil {:error, :module_not_found} -> nil end end @doc """ Get function documentation. Return doc string or `nil` if that function didn't have a documentation. """ @spec get_function_doc(module(), function_name()) :: String.t() | nil def get_function_doc(module, name) do fun = fn {{:function, ^name, _}, _, _, _, _} -> true _ -> false end with {_, function_docs} <- fetch_docs(module), {{:function, ^name, _}, _, _, doc_content, _} <- Enum.find(function_docs, fun), %{"en" => doc} <- doc_content do String.trim(doc) else nil -> nil :none -> nil :hidden -> nil end end defp fetch_docs(module) do {:docs_v1, _, :elixir, _, module_doc, _, function_docs} = Code.fetch_docs(module) {module_doc, function_docs} end defmacro __using__(opts) do name = opts[:name] quote do import Dagger.Mod.Object, only: [defn: 2, field: 2, field: 3, object: 1] import Dagger.Global, only: [dag: 0] Module.register_attribute(__MODULE__, :function, accumulate: true, persist: true) Module.register_attribute(__MODULE__, :field, accumulate: true, persist: true) # Get an object name def __object__(:name), do: unquote(name) # List available function definitions. def __object__(:functions) do __MODULE__.__info__(:attributes) |> Keyword.get_values(:function) |> Enum.flat_map(& &1) end # Get a function definition. def __object__(:function, name) do __object__(:functions) |> Keyword.fetch!(name) end # List available field definitions. def __object__(:fields) do __MODULE__.__info__(:attributes) |> Keyword.get_values(:field) |> Enum.flat_map(& &1) end end end @doc """ Declare a function. """ defmacro defn(call, do: block) do {name, args, return} = extract_call(call) has_self? = is_tuple(args) arg_defs = compile_args(args) return_def = compile_typespec!(return) quote do @function {unquote(name), %Dagger.Mod.Object.FunctionDef{ self: unquote(has_self?), args: unquote(arg_defs), return: unquote(return_def) }} unquote(Defn.define(name, args, return, block)) end end @doc """ Declare an object struct. """ defmacro object(do: block) do quote do Module.register_attribute(__MODULE__, :required_fields, accumulate: true) Module.register_attribute(__MODULE__, :optional_fields, accumulate: true) unquote(block) required_fields = @required_fields || [] optional_fields = @optional_fields || [] fields = @required_fields ++ @optional_fields # TODO: convert fields into typespec. @type t() :: %__MODULE__{} @derive [Jason.Encoder, {Nestru.Decoder, hint: Dagger.Mod.Object.decoder_hint(fields)}] @enforce_keys Keyword.keys(required_fields) defstruct fields |> Keyword.keys() |> Enum.sort() end end def decoder_hint(fields) do fields |> Enum.filter(&only_module/1) |> Enum.into(%{}, fn {name, field_def} -> type = case field_def.type do {:list, type} -> type {:optional, type} -> type type -> type end {name, type} end) end defp only_module({_, field_def}) do case field_def.type do {:list, type} -> module?(type) {:optional, type} -> module?(type) type -> module?(type) end end defp module?(type) do {:module, ^type} = Code.ensure_loaded(type) function_exported?(type, :__struct__, 0) end @doc """ Declare a field. """ defmacro field(name, type, opts \\ []) do type = compile_typespec!(type) optional? = match?({:optional, _}, type) doc = opts[:doc] field = Macro.escape({name, %Dagger.Mod.Object.FieldDef{type: type, doc: doc}}) quote do @field unquote(field) if unquote(optional?) do Module.put_attribute(__MODULE__, :optional_fields, unquote(field)) else Module.put_attribute(__MODULE__, :required_fields, unquote(field)) end end end defguardp is_self(self) when is_atom(elem(self, 0)) and is_nil(elem(self, 2)) defguardp is_args(args) when is_list(args) defp extract_call({:"::", _, [call_def, return]}) do {name, args} = extract_call_def(call_def) {name, args, return} end defp extract_call_def({name, _, []}) do {name, []} end defp extract_call_def({name, _, [self]}) when is_self(self) do {name, {self, []}} end defp extract_call_def({name, _, [args]}) when is_args(args) do {name, args} end defp extract_call_def({name, _, [self, args]}) when is_self(self) and is_args(args) do {name, {self, args}} end defp compile_args({_, args}) do compile_args(args) end defp compile_args(args) do for {name, spec} <- args do type = compile_typespec!(spec) meta = spec |> extract_options() |> Keyword.put(:type, type) {name, Meta.validate!(meta)} end end defp compile_typespec!({:integer, _, []}), do: :integer defp compile_typespec!({:float, _, []}), do: :float defp compile_typespec!({:boolean, _, []}), do: :boolean ## String defp compile_typespec!({:binary, _, []}), do: :string defp compile_typespec!( {{:., _, [ {:__aliases__, _, [:String]}, :t ]}, _, []} ) do :string end defp compile_typespec!({{:., _, [{:__aliases__, _, module}, :t]}, _, []}) do Module.concat(module) end ## List defp compile_typespec!({:list, _, [type]}) do {:list, compile_typespec!(type)} end defp compile_typespec!([type]) do {:list, compile_typespec!(type)} end ## Optional defp compile_typespec!({:|, _, [type, nil]}) do {:optional, compile_typespec!(type)} end ## Type with options defp compile_typespec!({type, _}) do compile_typespec!(type) end defp compile_typespec!(unsupported_type) do raise ArgumentError, "type `#{Macro.to_string(unsupported_type)}` is not supported" end defp extract_options({_, options}), do: options defp extract_options(_), do: [] end