Prismatic API

technology

Prismatic API

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
end

Type 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"}
end

Generic 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: #{app}/#{action}"})
    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
end

OpenAPI 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/#{ep.app}/#{ep.action}" 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 || "#{endpoint.app}/#{endpoint.action}",
      operationId: "#{endpoint.app}_#{endpoint.action}",
      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
end

Architecture 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:

LayerMechanismEnforcement
AuthenticationJWT bearer tokensRequired for all non-health endpoints
AuthorizationRBAC with role-based permissionsPer-endpoint permission checks
Rate LimitingToken bucket per API keyPrevents abuse and ensures fair access
CORSConfigurable origin allowlistBrowser-based API access control
CSRFDisabled for API (token-based auth)Not applicable to API-only application

Route Structure

RouteMethodPurpose
/api/v1/healthGETHealth check (no auth required)
/api/v1/endpointsGETList all discovered endpoints
/api/v1/:app/:actionGETDispatch to functions with 0-2 params
/api/v1/:app/:actionPOSTDispatch to functions with 3+ params
/api/openapiGETOpenAPI 3.0 JSON specification
/api/swagger-uiGETInteractive 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: #{metadata.module}.#{metadata.function} took #{duration_ms}ms")
    end
  end
end

Usage 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

CategoryModulesExample Endpoints
PerimeterPrismaticPerimeterdiscover/1, security_rating/1, assess_compliance/2
StoragePrismaticStorage.*get/2, put/3, list/1, search/2
AgentsPrismaticAgentslist/0, status/1, assign_task/2
OSINTPrismaticOSINTcollect/1, resolve_entity/1, search/2
QualityPrismaticSafetyquality_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-ui

Error 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
end

Best 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.

  • 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