Definition and Overview
Prismatic API (prismatic_api) is a standalone Phoenix application that automatically discovers all public functions across Prismatic facade modules and exposes them as a fully documented OpenAPI 3.0 REST API on port 4004. The application's defining characteristic is zero manual configuration: using Elixir's introspection capabilities (Code.fetch_docs/1, Code.Typespec.fetch_specs/1, Module.__info__/1), the scanner builds an ETS-cached endpoint registry at boot time, automatically mapping Elixir function signatures to REST endpoints with proper HTTP methods, parameter schemas, and response types. A generic dispatch controller resolves {app, action} tuples to module.function(args) calls, transforming the entire platform's programmatic interface into a REST API without writing a single endpoint definition.
The auto-introspection approach represents a fundamental architectural decision: rather than maintaining a separate API layer that must be manually synchronized with the underlying Elixir modules, Prismatic API generates its API surface directly from the source of truth -- the module signatures, typespecs, and documentation. When a developer adds a new public function to a facade module with proper @spec and @doc annotations, the API automatically exposes it at the next boot cycle. This eliminates the common problem of API documentation drift, where the documented API gradually diverges from the actual implementation.
The OpenAPI 3.0 specification generated by the scanner provides machine-readable API documentation that enables client SDK generation, automated testing, and interactive exploration through SwaggerUI. The specification includes parameter schemas derived from Elixir typespecs, response schemas, authentication requirements, and operation descriptions extracted from @doc attributes. This makes the Prismatic API not just a REST gateway but a self-documenting platform interface.
The generic dispatch architecture uses a single Phoenix controller (DispatchController) that handles all API requests. Rather than defining individual controller actions for each endpoint, the controller resolves the {app, action} pair from the URL path, looks up the corresponding module and function in the ETS registry, validates parameters against the registered schema, and calls safe_apply/3 to execute the function with appropriate error handling. This pattern dramatically reduces the code required to maintain the API layer while ensuring consistent behavior across all endpoints.
Historical Context and Motivation
The Prismatic API was born from a practical problem: the platform's 115 umbrella applications expose hundreds of public functions through facade modules, and creating individual REST endpoints for each function was unsustainable. The traditional approach -- writing Phoenix controllers, defining routes, documenting schemas, and keeping them synchronized with the underlying Elixir modules -- required linear effort proportional to the number of functions exposed.
The auto-introspection approach inverts this relationship. Instead of O(n) effort for n endpoints, the effort is O(1): build the scanner once, and every new facade function is automatically exposed. This architectural decision was influenced by GraphQL's introspection model, where the schema is self-describing, but applied to a REST API using OpenAPI as the schema format.
The choice of a standalone Phoenix application (rather than mounting the API within the existing Prismatic Web app on port 4000) provides operational isolation: the API can be scaled, deployed, and monitored independently of the LiveView dashboard. This separation of concerns follows the platform's architectural principle of single-responsibility applications within the umbrella.
Technical Deep Dive
Auto-Discovery Scanner
The scanner runs at boot time, introspecting all modules matching the Prismatic* facade pattern:
defmodule PrismaticAPI.Scanner do
@moduledoc """
Boot-time module scanner that discovers all Prismatic facade functions
and registers them as API endpoints in ETS.
"""
@facade_pattern ~r/^Elixir\.Prismatic[A-Z]\w*$/
@excluded_modules [PrismaticAPI, PrismaticAPI.Router, PrismaticAPI.Endpoint]
@type endpoint :: %{
app: String.t(),
action: String.t(),
module: module(),
function: atom(),
arity: non_neg_integer(),
method: :get | :post,
params: list(map()),
return_type: term(),
doc: String.t() | nil
}
@spec scan_and_register() :: {:ok, non_neg_integer()}
def scan_and_register do
endpoints =
:code.all_loaded()
|> Enum.map(fn {module, _} -> module end)
|> Enum.filter(&facade_module?/1)
|> Enum.reject(&(&1 in @excluded_modules))
|> Enum.flat_map(&discover_endpoints/1)
# Register in ETS for fast runtime lookup
table = :ets.new(:api_endpoints, [:set, :named_table, :public, read_concurrency: true])
Enum.each(endpoints, fn endpoint ->
key = {endpoint.app, endpoint.action}
:ets.insert(table, {key, endpoint})
end)
{:ok, length(endpoints)}
end
defp facade_module?(module) do
module_name = Atom.to_string(module)
Regex.match?(@facade_pattern, module_name) and has_public_functions?(module)
end
defp discover_endpoints(module) do
functions = module.__info__(:functions)
docs = fetch_docs(module)
specs = fetch_specs(module)
functions
|> Enum.filter(fn {name, _arity} -> public_api_function?(name) end)
|> Enum.map(fn {name, arity} ->
%{
app: module_to_app(module),
action: Atom.to_string(name),
module: module,
function: name,
arity: arity,
method: if(arity <= 2, do: :get, else: :post),
params: extract_params(specs, name, arity),
return_type: extract_return_type(specs, name, arity),
doc: extract_doc(docs, name, arity)
}
end)
end
defp public_api_function?(name) do
name_str = Atom.to_string(name)
not String.starts_with?(name_str, "_") and
name not in [:__struct__, :__info__, :module_info, :behaviour_info]
end
defp module_to_app(module) do
module
|> Module.split()
|> List.last()
|> Macro.underscore()
end
endType Mapping Engine
The type mapper converts Elixir @spec AST nodes to OpenAPI JSON Schema definitions:
defmodule PrismaticAPI.TypeMapper do
@moduledoc """
Maps Elixir typespec AST to OpenAPI 3.0 JSON Schema definitions.
Handles compound types, unions, structs, and custom types.
"""
@spec to_schema(term()) :: map()
def to_schema({:type, _, :integer, []}), do: %{"type" => "integer"}
def to_schema({:type, _, :float, []}), do: %{"type" => "number", "format" => "float"}
def to_schema({:type, _, :binary, []}), do: %{"type" => "string"}
def to_schema({:type, _, :boolean, []}), do: %{"type" => "boolean"}
def to_schema({:type, _, :atom, []}), do: %{"type" => "string", "description" => "Elixir atom"}
def to_schema({:type, _, :list, [inner]}) do
%{"type" => "array", "items" => to_schema(inner)}
end
def to_schema({:type, _, :map, _fields}) do
%{"type" => "object", "additionalProperties" => true}
end
def to_schema({:type, _, :union, types}) do
%{"oneOf" => Enum.map(types, &to_schema/1)}
end
def to_schema({:user_type, _, :String, [{:type, _, :t, []}]}) do
%{"type" => "string"}
end
def to_schema({:remote_type, _, [{:atom, _, DateTime}, {:atom, _, :t}, []]}) do
%{"type" => "string", "format" => "date-time"}
end
def to_schema({:type, _, :tuple, [{:atom, _, :ok}, inner]}) do
%{"type" => "object", "properties" => %{
"status" => %{"type" => "string", "enum" => ["ok"]},
"data" => to_schema(inner)
}}
end
def to_schema({:type, _, :tuple, [{:atom, _, :error}, inner]}) do
%{"type" => "object", "properties" => %{
"status" => %{"type" => "string", "enum" => ["error"]},
"reason" => to_schema(inner)
}}
end
# Fallback for unrecognized types
def to_schema(_), do: %{"type" => "object"}
endGeneric Dispatch Controller
The dispatch controller handles all API requests through a single action:
defmodule PrismaticAPI.DispatchController do
@moduledoc """
Generic dispatch controller that resolves {app, action} to module.function(args).
Single controller handles all API endpoints discovered by the scanner.
"""
use PrismaticAPI, :controller
alias OpenApiSpex.Operation
@spec dispatch(Plug.Conn.t(), map()) :: Plug.Conn.t()
def dispatch(conn, %{"app" => app, "action" => action} = params) do
case lookup_endpoint(app, action) do
{:ok, endpoint} ->
args = extract_args(params, endpoint)
case safe_apply(endpoint.module, endpoint.function, args) do
{:ok, result} ->
conn
|> put_status(200)
|> json(%{status: "ok", data: serialize(result)})
{:error, reason} ->
conn
|> put_status(422)
|> json(%{status: "error", reason: serialize(reason)})
end
:not_found ->
conn
|> put_status(404)
|> json(%{status: "error", reason: "Endpoint not found: #{}/#{}"})
end
end
defp lookup_endpoint(app, action) do
case :ets.lookup(:api_endpoints, {app, action}) do
[{_key, endpoint}] -> {:ok, endpoint}
[] -> :not_found
end
end
@spec safe_apply(module(), atom(), list()) :: {:ok, term()} | {:error, term()}
defp safe_apply(module, function, args) do
start_time = System.monotonic_time()
result =
try do
apply(module, function, args)
rescue
e -> {:error, %{exception: Exception.message(e), type: e.__struct__}}
end
duration = System.monotonic_time() - start_time
:telemetry.execute(
[:prismatic_api, :dispatch],
%{duration: duration},
%{module: module, function: function, arity: length(args)}
)
case result do
{:ok, _} = ok -> ok
{:error, _} = error -> error
other -> {:ok, other}
end
end
defp extract_args(params, endpoint) do
endpoint.params
|> Enum.map(fn param -> Map.get(params, param.name) end)
|> Enum.reject(&is_nil/1)
end
defp serialize(data) when is_struct(data), do: Map.from_struct(data)
defp serialize(data) when is_list(data), do: Enum.map(data, &serialize/1)
defp serialize(data) when is_map(data), do: data
defp serialize(data), do: data
endOpenAPI Specification Generation
The API spec is generated from the endpoint registry and type mappings:
defmodule PrismaticAPI.ApiSpec do
@moduledoc """
Generates OpenAPI 3.0 specification from discovered endpoints.
"""
alias OpenApiSpex.{Info, OpenApi, PathItem, Operation, MediaType, Schema, Response}
@spec generate() :: OpenApi.t()
def generate do
endpoints = :ets.tab2list(:api_endpoints) |> Enum.map(fn {_key, ep} -> ep end)
paths =
endpoints
|> Enum.group_by(fn ep -> "/api/v1/#{.}/#{.}" end)
|> Enum.map(fn {path, eps} -> {path, build_path_item(eps)} end)
|> Enum.into(%{})
%OpenApi{
info: %Info{
title: "Prismatic Platform API",
version: "1.0.0",
description: "Auto-introspecting REST API for the Prismatic Platform"
},
paths: paths
}
end
defp build_path_item([endpoint | _] = _endpoints) do
operation = %Operation{
summary: endpoint.doc || "#{.}/#{.}",
operationId: "#{.}_#{.}",
parameters: build_parameters(endpoint),
responses: %{
200 => %Response{
description: "Success",
content: %{"application/json" => %MediaType{
schema: PrismaticAPI.TypeMapper.to_schema(endpoint.return_type)
}}
}
}
}
case endpoint.method do
:get -> %PathItem{get: operation}
:post -> %PathItem{post: operation}
end
end
endArchitecture and Implementation
Request Flow
HTTP Request
|
v
+---------------------+
| PrismaticAPI.Router |
| /api/v1/:app/:action|
+----------+----------+
|
+------v------+
| Auth Plug | (JWT + RBAC validation)
| APIAuth |
+------+------+
|
+------v--------------+
| DispatchController |
| lookup_endpoint/2 |----> ETS Registry
| extract_args/2 |
| safe_apply/3 |----> Prismatic* Module
| serialize/1 |
+------+--------------+
|
+------v------+
| JSON Response|
+-------------+Authentication and Authorization
Prismatic API inherits the platform's authentication and authorization infrastructure:
| Layer | Mechanism | Enforcement |
|---|---|---|
| Authentication | JWT bearer tokens | Required for all non-health endpoints |
| Authorization | RBAC with role-based permissions | Per-endpoint permission checks |
| Rate Limiting | Token bucket per API key | Prevents abuse and ensures fair access |
| CORS | Configurable origin allowlist | Browser-based API access control |
| CSRF | Disabled for API (token-based auth) | Not applicable to API-only application |
Route Structure
| Route | Method | Purpose |
|---|---|---|
/api/v1/health | GET | Health check (no auth required) |
/api/v1/endpoints | GET | List all discovered endpoints |
/api/v1/:app/:action | GET | Dispatch to functions with 0-2 params |
/api/v1/:app/:action | POST | Dispatch to functions with 3+ params |
/api/openapi | GET | OpenAPI 3.0 JSON specification |
/api/swagger-ui | GET | Interactive Swagger UI documentation |
Telemetry and Observability
Every API dispatch emits telemetry events that feed into the platform's observability infrastructure:
defmodule PrismaticAPI.Telemetry do
@moduledoc """
Telemetry event definitions and handlers for API dispatch monitoring.
Tracks latency, error rates, and endpoint popularity.
"""
@spec attach_handlers() :: :ok
def attach_handlers do
:telemetry.attach_many(
"prismatic-api-handlers",
[
[:prismatic_api, :dispatch],
[:prismatic_api, :auth],
[:prismatic_api, :rate_limit]
],
&handle_event/4,
nil
)
end
defp handle_event([:prismatic_api, :dispatch], measurements, metadata, _config) do
duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
if duration_ms > 100 do
Logger.warning("Slow API dispatch: #{.}.#{.} took #{}ms")
end
end
endUsage in Prismatic Platform
Within the Prismatic Platform's 115-app umbrella, prismatic_api provides universal programmatic access to all platform capabilities through a single, self-documenting interface.
Discovered Endpoint Categories
| Category | Modules | Example Endpoints |
|---|---|---|
| Perimeter | PrismaticPerimeter | discover/1, security_rating/1, assess_compliance/2 |
| Storage | PrismaticStorage.* | get/2, put/3, list/1, search/2 |
| Agents | PrismaticAgents | list/0, status/1, assign_task/2 |
| OSINT | PrismaticOSINT | collect/1, resolve_entity/1, search/2 |
| Quality | PrismaticSafety | quality_score/0, gate_status/0, violations/0 |
Usage Examples
# Health check
curl http://localhost:4004/api/v1/health
# => {"status": "ok", "uptime_ms": 3600000}
# List all discovered endpoints
curl http://localhost:4004/api/v1/endpoints
# => {"endpoints": [{"app": "perimeter", "action": "discover", ...}, ...]}
# Discover attack surface
curl -X POST http://localhost:4004/api/v1/perimeter/discover \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"domain": "example.com"}'
# => {"status": "ok", "data": {"assets": [...], "rating": "B"}}
# Get security rating
curl http://localhost:4004/api/v1/perimeter/security_rating?domain=example.com \
-H "Authorization: Bearer $TOKEN"
# => {"status": "ok", "data": {"grade": "B", "score": 780}}
# Interactive API documentation
open http://localhost:4004/api/swagger-uiError Handling and Resilience
The dispatch controller implements defensive error handling that normalizes all response patterns into a consistent JSON structure. This is critical because facade modules across the platform use varying return conventions -- some return {:ok, data} tuples, others return raw values, and some may raise exceptions:
defmodule PrismaticAPI.ErrorNormalizer do
@moduledoc """
Normalizes diverse error patterns from facade modules
into consistent API error responses.
"""
@spec normalize(term()) :: %{status: String.t(), reason: String.t(), details: map()}
def normalize({:error, %Ecto.Changeset{} = changeset}) do
%{
status: "error",
reason: "validation_failed",
details: format_changeset_errors(changeset)
}
end
def normalize({:error, :not_found}) do
%{status: "error", reason: "not_found", details: %{}}
end
def normalize({:error, :unauthorized}) do
%{status: "error", reason: "unauthorized", details: %{}}
end
def normalize({:error, reason}) when is_atom(reason) do
%{status: "error", reason: Atom.to_string(reason), details: %{}}
end
def normalize({:error, reason}) when is_binary(reason) do
%{status: "error", reason: reason, details: %{}}
end
endBest Practices
Annotate facade functions with @spec and @doc. The auto-discovery scanner relies on typespecs for parameter schema generation and documentation for operation descriptions. Functions without proper annotations will have generic schemas and missing descriptions in the OpenAPI spec.
Design facade modules with API consumers in mind. Facade functions should accept and return simple data types that serialize naturally to JSON. Avoid returning opaque structs, PIDs, or BEAM-specific types that do not have meaningful JSON representations.
Use the endpoints listing for client development. The /api/v1/endpoints route provides a machine-readable inventory of all available API operations. Use this for automated client SDK generation, integration testing setup, and API compatibility verification.
Monitor dispatch latency. The telemetry events emitted by safe_apply/3 track per-endpoint execution time. Monitor these metrics to identify slow endpoints and optimize underlying module functions accordingly.
Version the API surface. While the /api/v1/ prefix provides versioning, facade module changes can break existing API consumers. Use semantic versioning on facade modules and consider API deprecation policies for breaking changes.
Common Pitfalls
Exposing internal functions through the API. The scanner discovers all public functions on matching modules. Functions intended for internal use (helper functions, callbacks) may be inadvertently exposed. Use naming conventions (prefix with underscore) or configure explicit exclusion lists.
Missing type mapping for complex types. The type mapper handles standard Elixir types but may produce generic object schemas for complex custom types. Extend the mapper for platform-specific types or simplify facade function signatures.
Ignoring authentication on new endpoints. The scanner automatically discovers new functions, but authentication and authorization rules may not be configured for them. Ensure the RBAC policy covers all discoverable endpoint patterns.
Performance impact of boot-time scanning. Scanning all loaded modules adds to boot time. Cache the scan results in ETS (already implemented) and consider incremental scanning for development environments where boot speed matters.
Inconsistent error handling across facade modules. The dispatch controller normalizes errors, but inconsistent return patterns across facade modules ({:ok, _} vs raw values vs exceptions) can produce confusing API responses. Enforce the {:ok, _} | {:error, _} pattern on all facade functions.
Related Concepts
- REST API -- Architectural style that Prismatic API implements
- OpenAPI -- Specification standard for API documentation and schema generation
- Phoenix -- Web framework powering the API application
- JWT -- Token format used for API authentication
- RBAC -- Role-based access control enforced on all API endpoints
- ETS Table -- In-memory storage backing the endpoint registry
- Prismatic Web -- Companion web application serving LiveView dashboards
- Telemetry -- Instrumentation for dispatch latency monitoring
- Typespec -- Type annotations driving OpenAPI schema generation
- EASM -- Perimeter security module exposed through the API
See Also
- Architecture -- Platform architecture overview
- Apps -- Umbrella applications exposed through the API
- Technologies -- Technology stack powering the API
- Capabilities -- Platform capabilities accessible via API
Connect & Contribute
Created by Tomas Korcak (korczis) | Open Source under GHL