Single-source API declarations for self-describing Elixir functions.
The api macro is the sole source of truth for function documentation.
It generates @doc text, emits @doc hints: metadata for machine consumption,
validates param names at compile time, and produces __api__/0 and __api__/1
introspection functions.
Usage
defmodule MyLib.Funding do
use Descripex, namespace: "/funding"
api(:annualize, "Annualize a per-period funding rate to APR.",
params: [
rate: [kind: :value, description: "Per-period funding rate as decimal"],
period_hours: [kind: :value, default: 8, description: "Hours per period"]
],
returns: %{type: :float, description: "Annualized percentage rate (APR)"}
)
@spec annualize(number(), pos_integer()) :: float()
def annualize(rate, period_hours \\ 8), do: ...
endNo separate @doc block needed — the macro generates it from the declaration.
Introspection
MyLib.Funding.__api__()
# => [%{name: :annualize, arity: 2, ...}, ...]
MyLib.Funding.__api__(:annualize)
# => %{name: :annualize, arity: 2, param_order: [:rate, :period_hours], spec: "...", hints: %{...}}The param_order field lists the positional parameter names in declaration
order (including defaulted params). Consumers that dispatch named arguments
positionally — e.g. mapping MCP/JSON tool arguments onto
apply(module, fun, args) — must order arguments by param_order, not by
Map.keys(hints.params). The hints[:params] map discards declaration order,
so Map.keys/1 returns hash order and silently swaps multi-parameter calls.
param_order lists every declared positional param, including those with
defaults. A consumer that omits an optional argument must dispatch on the
function's lower arity rather than blindly mapping all of param_order — the
defaulted tail can be dropped from the right.
__api__/0 vs the BEAM doc chunk
__api__/0 is the runtime-enriched introspection surface: it fills
hints.params.<name>.schema / hints.opts.<name>.schema from the function's
@spec and declared type: via enrich_with_specs/2. The BEAM doc chunk
(Code.fetch_docs/1 → meta[:hints]) is the raw declared surface — written
at compile time, before the module can read its own specs, so it is not enriched.
This asymmetry is intentional. The two surfaces therefore diverge on :schema
for any param/opt that gains a spec-derived schema. Consumers that assert the two
are equal (e.g. to verify each @doc hints: block is attached to the correctly
named function) must not compare them raw — normalize both with
normalize_for_doc_compare/1, which strips every :schema key:
Descripex.normalize_for_doc_compare(Mod.__api__(:f).hints) ==
Descripex.normalize_for_doc_compare(meta_hints)
Summary
Functions
Return the list of modules registered with this library.
Build machine-readable hints map from an api declaration's description and options.
Return a Level 1 overview of all modules in this library.
Return Level 2 function list for a module (by full atom or short name).
Return Level 3 function detail (or nil if not found).
Declare an api whose opts is a compile-time variable, not a literal keyword list.
Enrich compile-time api entries with specs fetched at runtime.
Generate human-readable @doc text from an api declaration's description and options.
Strip every :schema key from a hints map so the runtime-enriched __api__/0
surface can be compared for equality against the raw compile-time doc chunk
(Code.fetch_docs/1 → meta[:hints]).
Functions
@spec __descripex_modules__() :: [module()]
Return the list of modules registered with this library.
Build machine-readable hints map from an api declaration's description and options.
@spec describe() :: [map()]
Return a Level 1 overview of all modules in this library.
Return Level 2 function list for a module (by full atom or short name).
Return Level 3 function detail (or nil if not found).
Declare an api whose opts is a compile-time variable, not a literal keyword list.
api/3 runs preprocess_schemas/1 on the opts AST at macro-expansion time, which
only works when opts is a literal keyword-list AST. Callers that build opts inside
a for-comprehension or any other macro-time variable cannot use api/3. emit_api/3
emits the identical @doc, @doc hints:, and accumulator entry as api/3, but skips
schema preprocessing — so it accepts a variable opts AST.
Compile-time validation (__before_compile__) still fires for emit_api/3 declarations,
identically to api/3, since both accumulate into @descripex_api_declarations.
Schema keys are NOT preprocessed
Because preprocessing is skipped, the caller is responsible for pre-converting any
schema: keys to JSON Schema maps before passing them in. For-comprehension callers
typically declare no schema: keys. If you have a literal opts keyword list
(with or without schema:), use api/3 instead — emit_api/3 raises ArgumentError
on a literal keyword-list opts to steer you to the macro that runs preprocessing.
Example
for {name, opts} <- compile_time_method_defs() do
emit_api(name, "Generated declaration", opts)
end
Enrich compile-time api entries with specs fetched at runtime.
Generate human-readable @doc text from an api declaration's description and options.
Strip every :schema key from a hints map so the runtime-enriched __api__/0
surface can be compared for equality against the raw compile-time doc chunk
(Code.fetch_docs/1 → meta[:hints]).
__api__/0 fills hints.params.<name>.schema / hints.opts.<name>.schema from
@spec/type: at runtime (see enrich_with_specs/2), but the doc chunk is
written at compile time and is not enriched — a module can't read its own
specs at __before_compile__. So the two surfaces diverge on :schema, and a
consumer that asserts they are equal (e.g. to detect api() misattachment)
false-positives purely on the injected schema.
This drops all schema keys — author-declared and spec-injected alike, which
are indistinguishable once merged — from :params, :opts, and :returns.
Apply it to both surfaces before comparing:
Descripex.normalize_for_doc_compare(Mod.__api__(:f).hints) ==
Descripex.normalize_for_doc_compare(meta_hints)