|
| 1 | +# SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors> |
| 2 | +# |
| 3 | +# SPDX-License-Identifier: MIT |
| 4 | + |
| 5 | +defmodule Mix.Tasks.Gen.ApiDocs do |
| 6 | + @shortdoc "Generates per-domain API markdown fragments from code_interface defines" |
| 7 | + @moduledoc """ |
| 8 | + Walks each configured domain's `code_interface` defines and writes a |
| 9 | + markdown table fragment for inclusion in the domain doc pages. |
| 10 | +
|
| 11 | + Each fragment lists the resource sections, with one row per `define` |
| 12 | + showing the generated Elixir function, the underlying action, the |
| 13 | + meaningful arguments, and the action's purpose (its `description:`). |
| 14 | +
|
| 15 | + ## Usage |
| 16 | +
|
| 17 | + mix gen.api_docs |
| 18 | +
|
| 19 | + Writes to: |
| 20 | +
|
| 21 | + - `documentation/domains/_access_api.md` |
| 22 | + - `documentation/domains/_nbn_api.md` |
| 23 | +
|
| 24 | + These fragments are intended to be referenced (e.g. via `!include` |
| 25 | + pseudo-markers or just kept open in the editor alongside the narrative |
| 26 | + page). They're regenerated on demand so they don't drift from the |
| 27 | + code-interface declarations. |
| 28 | + """ |
| 29 | + |
| 30 | + use Mix.Task |
| 31 | + |
| 32 | + @doc_root "documentation/domains" |
| 33 | + |
| 34 | + @domains [ |
| 35 | + {DiffoExample.Access, "_access_api.md", "Access"}, |
| 36 | + {DiffoExample.Nbn, "_nbn_api.md", "NBN"} |
| 37 | + ] |
| 38 | + |
| 39 | + @autogen_banner """ |
| 40 | + <!-- |
| 41 | + SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors> |
| 42 | +
|
| 43 | + SPDX-License-Identifier: MIT |
| 44 | +
|
| 45 | + Auto-generated by `mix gen.api_docs`. Do not edit by hand. |
| 46 | + Regenerate after changing any domain's `code_interface` defines. |
| 47 | + --> |
| 48 | + """ |
| 49 | + |
| 50 | + @impl Mix.Task |
| 51 | + def run(_args) do |
| 52 | + Mix.Task.run("compile", []) |
| 53 | + |
| 54 | + File.mkdir_p!(@doc_root) |
| 55 | + |
| 56 | + Enum.each(@domains, fn {domain, file, title} -> |
| 57 | + path = Path.join(@doc_root, file) |
| 58 | + content = render_domain(domain, title) |
| 59 | + File.write!(path, content) |
| 60 | + Mix.shell().info("wrote #{path}") |
| 61 | + end) |
| 62 | + end |
| 63 | + |
| 64 | + defp render_domain(domain, title) do |
| 65 | + refs = |
| 66 | + domain |
| 67 | + |> Ash.Domain.Info.resource_references() |
| 68 | + |> Enum.sort_by(&short_name(&1.resource)) |
| 69 | + |
| 70 | + body = |
| 71 | + refs |
| 72 | + |> Enum.map(&render_resource/1) |
| 73 | + |> Enum.reject(&(&1 == :empty)) |
| 74 | + |> Enum.join("\n\n") |
| 75 | + |
| 76 | + [ |
| 77 | + @autogen_banner, |
| 78 | + "\n", |
| 79 | + "# #{title} Domain API\n", |
| 80 | + "\n", |
| 81 | + "The Elixir function-call surface for each resource in the `#{inspect(domain)}` domain. ", |
| 82 | + "Generated from the `define` declarations in the domain's `resources do` block.\n", |
| 83 | + "\n", |
| 84 | + body, |
| 85 | + "\n" |
| 86 | + ] |
| 87 | + |> IO.iodata_to_binary() |
| 88 | + end |
| 89 | + |
| 90 | + defp render_resource(ref) do |
| 91 | + case ref.definitions do |
| 92 | + [] -> |
| 93 | + :empty |
| 94 | + |
| 95 | + defs -> |
| 96 | + sorted = Enum.sort_by(defs, & &1.name) |
| 97 | + |
| 98 | + rows = |
| 99 | + sorted |
| 100 | + |> Enum.map(&render_row(&1, ref.resource)) |
| 101 | + |> Enum.join("\n") |
| 102 | + |
| 103 | + """ |
| 104 | + ## #{short_name(ref.resource)} |
| 105 | +
|
| 106 | + | Function | Action | Arguments | Purpose | |
| 107 | + |---|---|---|---| |
| 108 | + #{rows} |
| 109 | + """ |
| 110 | + |> String.trim_trailing() |
| 111 | + end |
| 112 | + end |
| 113 | + |
| 114 | + defp render_row(interface, resource) do |
| 115 | + action_name = interface.action || interface.name |
| 116 | + action = Ash.Resource.Info.action(resource, action_name) |
| 117 | + |
| 118 | + fn_cell = "`#{interface.name}`" |
| 119 | + action_cell = "`:#{action_name}`" |
| 120 | + args_cell = render_args(interface, action) |
| 121 | + purpose_cell = render_purpose(action) |
| 122 | + |
| 123 | + "| #{fn_cell} | #{action_cell} | #{args_cell} | #{purpose_cell} |" |
| 124 | + end |
| 125 | + |
| 126 | + # Auto-injected by Diffo's `behaviour do create :build end` fragment — |
| 127 | + # not part of the user-facing API surface. |
| 128 | + @injected_build_args [:specified_by, :features, :characteristics] |
| 129 | + |
| 130 | + defp render_args(interface, action) do |
| 131 | + get_by_arg = |
| 132 | + cond do |
| 133 | + interface.get_by -> Enum.map(List.wrap(interface.get_by), &"`#{&1}`") |
| 134 | + interface.get_by_identity -> ["`#{interface.get_by_identity}`"] |
| 135 | + true -> [] |
| 136 | + end |
| 137 | + |
| 138 | + accept = Enum.map(Map.get(action, :accept) || [], &"`#{&1}`") |
| 139 | + |
| 140 | + arguments = |
| 141 | + (Map.get(action, :arguments) || []) |
| 142 | + |> Enum.reject(&(&1.name in @injected_build_args)) |
| 143 | + |> Enum.map(fn arg -> "`#{arg.name}` (#{type_label(arg.type)})" end) |
| 144 | + |
| 145 | + case get_by_arg ++ accept ++ arguments do |
| 146 | + [] -> "—" |
| 147 | + list -> Enum.join(list, ", ") |
| 148 | + end |
| 149 | + end |
| 150 | + |
| 151 | + defp render_purpose(action) do |
| 152 | + case action.description do |
| 153 | + nil -> "—" |
| 154 | + "" -> "—" |
| 155 | + desc -> desc |> String.replace("\n", " ") |> String.trim() |
| 156 | + end |
| 157 | + end |
| 158 | + |
| 159 | + defp type_label({:array, type}), do: "list of #{type_label(type)}" |
| 160 | + |
| 161 | + defp type_label(type) when is_atom(type) do |
| 162 | + type |
| 163 | + |> to_string() |
| 164 | + |> String.replace_prefix("Elixir.Ash.Type.", "") |
| 165 | + |> String.replace_prefix("Elixir.", "") |
| 166 | + |> String.downcase() |
| 167 | + end |
| 168 | + |
| 169 | + defp type_label(other), do: inspect(other) |
| 170 | + |
| 171 | + defp short_name(module) do |
| 172 | + module |> Module.split() |> List.last() |
| 173 | + end |
| 174 | +end |
0 commit comments