defmodule OuterfacesEctoApi.Codegen.JsCodegen do @moduledoc """ Generates JavaScript type definitions from Ecto schemas. """ @spec generate_js_typedef( schema :: String.t(), controller_module :: String.t() | nil ) :: String.t() def generate_js_typedef(schema, nil) do schema_name = schema |> String.split(".") |> List.last() schema_module = ("Elixir." <> schema) |> String.to_existing_atom() fields_defs = build_field_defs(schema_module) association_defs = build_association_defs(schema_module) """ /** * @typedef {Object} #{schema_name} #{fields_defs} #{association_defs} */ """ end def generate_js_typedef(schema, controller_module_name) when is_binary(controller_module_name) do schema_name = schema |> String.split(".") |> List.last() schema_module = ("Elixir." <> schema) |> String.to_existing_atom() controller_module_atom = "Elixir." <> controller_module_name Code.ensure_loaded?(String.to_atom(controller_module_atom)) controller_module = controller_module_atom |> String.to_existing_atom() fields_defs = build_field_defs(schema_module) index_association_defs = build_association_defs(schema_module, controller_module, :index_preloads, 0, []) show_association_defs = build_association_defs(schema_module, controller_module, :show_preloads, 0, []) controller_has_index_method = function_exported?(controller_module, :index, 2) controller_has_show_method = function_exported?(controller_module, :show, 2) base = "" base = if controller_has_index_method do base <> generate_index_typedef( schema_name, fields_defs, index_association_defs ) else base end if controller_has_show_method do base <> generate_show_typedef( schema_name, fields_defs, show_association_defs ) else base end end defp generate_index_typedef( schema_name, fields_defs, index_association_defs ) do """ /** * @typedef {Object} #{schema_name}IndexData #{fields_defs} #{index_association_defs} */ /** * @typedef {Object} Fetch#{schema_name}IndexQueryParams * @property {Object} [filters] - Filters to apply to the query * @property {string[]} [sort] - Sort order for the query. Format: ['sort_name:asc', 'sort_name:desc'] * @property {number} [limit] - Maximum number of records to return * @property {number} [offset] - Offset for the query * */ /** * @typedef {Object} Fetch#{schema_name}IndexQueryResult * @property {number} status, * @property {Object} results * @property {#{schema_name}IndexData[]} results.data * @property {string} results.schema * @property {PageInfo} results.page_info */ """ end defp generate_show_typedef( schema_name, fields_defs, show_association_defs ) do """ /** * @typedef {Object} #{schema_name}ShowData #{fields_defs} #{show_association_defs} */ /** * @typedef {Object} Fetch#{schema_name}ShowQueryResult * @property {number} status * @property {Object} result * @property {#{schema_name}ShowData} result.data * @property {string} result.schema */ """ end defp ecto_type_to_js_type(:integer), do: "number" defp ecto_type_to_js_type(:id), do: "number" defp ecto_type_to_js_type(:string), do: "string" defp ecto_type_to_js_type(:boolean), do: "boolean" defp ecto_type_to_js_type(:float), do: "number" defp ecto_type_to_js_type(:naive_datetime), do: "string" defp ecto_type_to_js_type(:utc_datetime), do: "string" defp ecto_type_to_js_type(:date), do: "string" defp ecto_type_to_js_type(:map), do: "Record" defp ecto_type_to_js_type(_), do: "any" @spec build_field_defs(schema_module :: module()) :: String.t() defp build_field_defs(schema_module) do Code.ensure_loaded(schema_module) maybe_skip_fields = (function_exported?(schema_module, :serializer_skip_fields, 1) && apply(schema_module, :serializer_skip_fields, [nil])) || [] schema_module.__schema__(:fields) |> Enum.reject(fn field -> field in maybe_skip_fields end) |> Enum.map(fn field -> type = ecto_type_to_js_type(schema_module.__schema__(:type, field)) " * @property {#{type}} #{field}" end) |> Enum.join("\n") end @spec build_association_defs( schema_module :: module(), preloader_module :: module(), function :: atom(), arity :: non_neg_integer(), arguments :: [term()] ) :: String.t() defp build_association_defs( schema_module, preloader_module, function, arity, arguments ) do preloads = (function_exported?(preloader_module, function, arity) && apply(preloader_module, function, arguments)) || [] preload_keys = Enum.map(preloads, fn {key, _value} -> key key -> key end) build_filtered_association_defs(schema_module, preload_keys) end @spec build_association_defs( schema_module :: module(), associations :: [atom()] ) :: String.t() defp build_association_defs(schema_module, associations) do associations |> Enum.map(fn assoc -> refl = schema_module.__schema__(:association, assoc) assoc_module = case refl do %{queryable: queryable} -> queryable |> to_string() |> String.split(".") |> List.last() %Ecto.Association.HasThrough{} -> fetch_final_module_in_chain(schema_module, refl.through) end assoc_module = Macro.camelize(assoc_module) assoc_module = if refl.cardinality == :many do "#{assoc_module}[]" else assoc_module end " * @property {#{assoc_module} | null} #{assoc}" end) |> Enum.join("\n") end @spec build_association_defs(schema_module :: module()) :: String.t() defp build_association_defs(schema_module) do associations = schema_module.__schema__(:associations) build_association_defs(schema_module, associations) end @spec build_filtered_association_defs( schema_module :: module(), preload_keys :: [atom()] ) :: String.t() defp build_filtered_association_defs(schema_module, preload_keys) do associations = schema_module.__schema__(:associations) |> Enum.filter(fn assoc -> assoc in preload_keys end) build_association_defs(schema_module, associations) end defp fetch_final_module_in_chain(root_module, through_chain) do Enum.reduce(through_chain, root_module, fn assoc, current_mod -> sub_refl = current_mod.__schema__(:association, assoc) if is_nil(sub_refl) do raise ArgumentError, "Invalid association in `through:` chain" <> " for top-level schema `#{inspect(root_module)}`: " <> "`#{assoc}` not found on intermediate schema `#{inspect(current_mod)}`" end sub_refl.queryable end) |> to_string() |> String.split(".") |> List.last() end end