Back to Blog
Architecture March 11, 2026 | 9 min read

REST API Auto-Discovery: From Elixir Modules to Endpoints

How Prismatic auto-discovers API endpoints by scanning Elixir modules, extracting @spec definitions, and generating a generic dispatch controller with type coercion.

Tomas Korcak (korczis)

Prismatic Platform

One of the more unusual architectural decisions in the Prismatic Platform is that REST API endpoints are not hand-written. Instead, the system scans Elixir modules at compile time, extracts their @spec definitions and documentation, and generates a unified dispatch controller that routes HTTP requests to the appropriate function with automatic parameter coercion.


This approach eliminates the traditional boilerplate of writing controller actions, request validation, and response serialization for every endpoint. When a developer adds a new public function with a @spec and @doc, it automatically becomes available as an API endpoint.


The Discovery Pipeline


The auto-discovery pipeline runs in four stages during compilation:


StageModulePurpose

|-------|--------|---------|

1. ScanApiDiscovery.ScannerFind modules with @api_expose attribute 2. ExtractApiDiscovery.ExtractorParse @spec, @doc, and parameter metadata 3. MapApiDiscovery.MapperConvert typespecs to OpenAPI schema + route definitions 4. RegisterApiDiscovery.RegistryStore endpoint definitions in ETS for runtime dispatch

Module Scanning with Code.fetch_docs


The scanner identifies modules that opt into API exposure using a module attribute. It then uses Code.fetch_docs/1 to extract documentation and Code.Typespec.fetch_specs/1 to get type specifications:



defmodule PrismaticWeb.ApiDiscovery.Scanner do

@moduledoc """

Scans compiled modules for API-exposable functions.


Uses Code.fetch_docs/1 and beam_lib to extract

documentation and typespecs from compiled .beam files.

"""


@spec scan_modules() :: [module_info()]

def scan_modules do

:code.all_loaded()

|> Enum.filter(&has_api_attribute?/1)

|> Enum.flat_map(&extract_endpoints/1)

end


defp has_api_attribute?({module, _beam_file}) do

case module.__info__(:attributes)[:api_expose] do

nil -> false

_ -> true

end

end


defp extract_endpoints({module, _beam_file}) do

{:docs_v1, _, :elixir, _, _, _, docs} = Code.fetch_docs(module)

specs = fetch_specs(module)


for {{:function, name, arity}, _, _, doc, metadata} <- docs,

Map.has_key?(metadata, :api),

spec = Map.get(specs, {name, arity}) do

%{

module: module,

function: name,

arity: arity,

doc: extract_doc_text(doc),

spec: spec,

http_method: Map.get(metadata, :api_method, :get),

path: Map.get(metadata, :api_path, build_default_path(module, name))

}

end

end


defp fetch_specs(module) do

case Code.Typespec.fetch_specs(module) do

{:ok, specs} -> Map.new(specs, fn {{name, arity}, spec} -> {{name, arity}, spec} end)

:error -> %{}

end

end

end


Exposing Functions as Endpoints


A domain module opts into API exposure with the @api_expose attribute and annotates individual functions with @doc metadata:



defmodule PrismaticOsint.EntitySearch do

@moduledoc """

Entity search across multiple OSINT sources.

Auto-exposed as REST API via ApiDiscovery.

"""


@api_expose true


@doc """

Search for entities matching the given query.


## API Metadata

api: true

api_method: :get

api_path: "/api/v1/osint/search"

"""

@spec search(String.t(), keyword()) :: {:ok, [Entity.t()]} | {:error, term()}

def search(query, opts \\ []) do

sources = Keyword.get(opts, :sources, [:all])

limit = Keyword.get(opts, :limit, 25)


OsintMesh.search(query, sources, limit: limit)

end

end


Spec-to-OpenAPI Mapping


The mapper translates Elixir typespecs into OpenAPI 3.0 schema definitions. This mapping handles common Elixir types including unions, keyword lists, and custom structs:



defmodule PrismaticWeb.ApiDiscovery.Mapper do

@moduledoc """

Maps Elixir typespecs to OpenAPI 3.0 schema definitions.


Handles primitive types, unions, structs, keyword lists,

and custom type references with recursive resolution.

"""


@spec typespec_to_schema(term()) :: map()

def typespec_to_schema({:type, _, :binary, []}) do

%{"type" => "string"}

end


def typespec_to_schema({:type, _, :integer, []}) do

%{"type" => "integer"}

end


def typespec_to_schema({:type, _, :float, []}) do

%{"type" => "number", "format" => "float"}

end


def typespec_to_schema({:type, _, :boolean, []}) do

%{"type" => "boolean"}

end


def typespec_to_schema({:type, _, :list, [inner]}) do

%{"type" => "array", "items" => typespec_to_schema(inner)}

end


def typespec_to_schema({:type, _, :union, types}) do

%{"oneOf" => Enum.map(types, &typespec_to_schema/1)}

end


def typespec_to_schema({:remote_type, _, [{:atom, _, module}, {:atom, _, :t}, []]}) do

%{"$ref" => "#/components/schemas/#{inspect(module)}"}

end


def typespec_to_schema({:type, _, :map, fields}) do

properties =

for {:type, _, :map_field_exact, [{:atom, _, key}, value_type]} <- fields,

into: %{} do

{Atom.to_string(key), typespec_to_schema(value_type)}

end


%{"type" => "object", "properties" => properties}

end

end


Elixir TypeOpenAPI SchemaNotes

|-------------|---------------|-------|

String.t(){"type": "string"}Direct mapping integer(){"type": "integer"}Direct mapping float(){"type": "number", "format": "float"}Includes format hint boolean(){"type": "boolean"}Direct mapping [Entity.t()]{"type": "array", "items": {"$ref": ...}}Recursive resolution `String.t() \nil`{"oneOf": [...], "nullable": true}Union handling keyword(){"type": "object"}Converted to query params

Generic Dispatch Controller


The dispatch controller is the single entry point for all auto-discovered endpoints. It performs parameter extraction, type coercion, function invocation, and response envelope wrapping:



defmodule PrismaticWeb.Api.DispatchController do

@moduledoc """

Generic API dispatch controller for auto-discovered endpoints.


Handles parameter extraction from query string, path params,

and request body. Performs type coercion based on the registered

spec, invokes the target function, and wraps the result in

a standard API envelope.

"""


use PrismaticWeb, :controller


alias PrismaticWeb.ApiDiscovery.Registry


@spec dispatch(Plug.Conn.t(), map()) :: Plug.Conn.t()

def dispatch(conn, %{"endpoint_id" => endpoint_id} = params) do

with {:ok, endpoint} <- Registry.lookup(endpoint_id),

{:ok, coerced_params} <- coerce_params(params, endpoint.spec),

{:ok, result} <- apply(endpoint.module, endpoint.function, build_args(coerced_params)) do

conn

|> put_status(200)

|> json(%{

data: serialize(result),

meta: %{

request_id: conn.assigns[:request_id],

timestamp: DateTime.utc_now(),

api_version: "v1"

}

})

else

{:error, :not_found} ->

conn |> put_status(404) |> json(%{error: "Endpoint not found"})


{:error, %{type: :coercion, details: details}} ->

conn |> put_status(422) |> json(%{error: "Invalid parameters", details: details})


{:error, reason} ->

conn |> put_status(500) |> json(%{error: "Internal error", details: inspect(reason)})

end

end

end


Type Coercion Pipeline


HTTP parameters arrive as strings. The coercion pipeline converts them to the types expected by the target function based on the registered spec:



defmodule PrismaticWeb.ApiDiscovery.Coercion do

@moduledoc """

Type coercion for HTTP parameters based on registered typespecs.


Converts string parameters from query strings and path params

to their expected Elixir types with validation.

"""


@spec coerce(String.t(), atom()) :: {:ok, term()} | {:error, String.t()}

def coerce(value, :integer) do

case Integer.parse(value) do

{int, ""} -> {:ok, int}

_ -> {:error, "expected integer, got: #{value}"}

end

end


def coerce(value, :boolean) do

case String.downcase(value) do

v when v in ["true", "1", "yes"] -> {:ok, true}

v when v in ["false", "0", "no"] -> {:ok, false}

_ -> {:error, "expected boolean, got: #{value}"}

end

end


def coerce(value, :atom) do

{:ok, String.to_existing_atom(value)}

rescue

ArgumentError -> {:error, "unknown atom: #{value}"}

end


def coerce(value, :string), do: {:ok, value}


def coerce(value, {:list, inner_type}) do

value

|> String.split(",", trim: true)

|> Enum.reduce_while({:ok, []}, fn item, {:ok, acc} ->

case coerce(String.trim(item), inner_type) do

{:ok, coerced} -> {:cont, {:ok, [coerced | acc]}}

{:error, _} = err -> {:halt, err}

end

end)

|> case do

{:ok, list} -> {:ok, Enum.reverse(list)}

error -> error

end

end

end


Route Generation


The router integrates with Phoenix's routing DSL. At compile time, auto-discovered endpoints are injected into the :api pipeline scope:



# In router.ex β€” auto-discovered routes

scope "/api/v1", PrismaticWeb.Api do

pipe_through [:api, :api_auth]


# Auto-generated from ApiDiscovery.Registry

for endpoint <- PrismaticWeb.ApiDiscovery.Registry.all_endpoints() do

match endpoint.http_method, endpoint.path,

DispatchController, :dispatch,

assigns: %{endpoint_id: endpoint.id}

end

end


This architecture means adding a new API endpoint requires zero changes to controllers, routers, or serializers. Define a function with @spec and @doc, mark it with the API metadata, and it appears in the API automatically, complete with OpenAPI documentation, parameter validation, and type-safe coercion.


The tradeoff is increased compile-time complexity and a less conventional code path. Debugging requires understanding the dispatch layer. But for a platform with hundreds of domain functions that need API exposure, the elimination of repetitive controller code is significant.

Tags

api elixir auto-discovery metaprogramming openapi

Related Glossary Terms