Prauga FlexDoc for Plug and Phoenix

prauga_flexdoc 0.4.5 is a self-contained Plug that packages the canonical FlexDoc renderer.

Plug

plug PraugaFlexDoc.Plug,
path: "/docs",
spec_url: "/openapi.json",
title: "My API"

The Plug config also accepts expand, try_it_default_server, try_it_credentials, and try_it_api_client_persistence_key; expansion may be a preset string or section list, and persistence may be a string or false.

Native API-host execution (3.3)

Create a framework-neutral native executor with an explicit exact-origin allowlist, protect the docs/execute subtree with application authentication/authorization plugs, then acknowledge that boundary explicitly:

host_execution =
PraugaFlexDoc.HostExecution.new!([
"https://api.example.internal"
])
plug PraugaFlexDoc.Plug,
path: "/docs",
spec_url: "/openapi.json",
title: "My API",
try_it_enabled: true,
try_it_host_execution: true,
host_execution_protected: true,
host_execution: host_execution

When both try_it_host_execution: true and a real PraugaFlexDoc.HostExecution are configured, the Plug owns POST /docs/__flexdoc/execute and the renderer advertises hostExecution.available: true. Enabling the option without an executor keeps available: false and the execute path returns 404.

Fail-closed rule: Plug initialization raises ArgumentError when host execution is available but host_execution_protected: true is absent. The option is only an explicit assertion that the surrounding Plug/Phoenix pipeline already authenticates and authorizes the documentation/execute surface; it does not install authentication itself. The outbound origin allowlist is not user authentication.

The Elixir host consumes the existing FlexDoc JSON or canonical multipart execute envelope. It requires X-FlexDoc-Execute: 1, accepts only explicitly allowlisted HTTP(S) origins, strips unsafe transport headers, revalidates same-origin redirects, bounds incoming envelopes to 32 MiB and returned response bodies to 10 MiB, and applies a full-request deadline. Basic, Bearer, OAuth2 bearer-token, and header/query API-key auth are supported as canonical request-draft features.

This first Elixir slice intentionally advertises an empty host-only capability list. Session cookie jars, client certificates, Digest, Hawk, NTLM/Negotiate, OAuth 1.0, and AWS Signature V4 remain unavailable until they are implemented natively.

The Elixir executor resolves and validates the target before each request or redirect, rejects link-local/cloud-metadata addresses, and uses Mint to connect directly to one of the validated IP addresses. The original hostname remains the HTTP Host and TLS server name/certificate identity, so the socket cannot silently re-resolve to a different address between validation and connection.

Execution evidence

An executor that reports nothing leaves an operator guessing whether a failing Try It is a policy rejection, a slow upstream or traffic that never carried an execute marker. Pass a metric sink to emit the same metric names, labels and reason vocabulary as every other FlexDoc host, so one collector reads a mixed fleet:

{:ok, recorder} = PraugaFlexDoc.HostExecutionObservation.start_link(name: MyApp.FlexDocEvidence)
executor =
PraugaFlexDoc.HostExecution.new!(["https://api.example.internal"],
metric_sink: PraugaFlexDoc.HostExecutionObservation.sink(MyApp.FlexDocEvidence)
)
# Whenever an operator asks for evidence:
report = PraugaFlexDoc.HostExecutionObservation.report(MyApp.FlexDocEvidence)

The sink receives PraugaFlexDoc.HostExecutionMetric structs carrying a name, kind, value and labels, and nothing else: no URL, header, body or credential reaches it. Bridge it to :telemetry, Prometheus or OpenTelemetry where such a stack exists; where none does, the recorder folds the same updates into an aggregate and report/2 produces the shared flexdoc.host-execution.observation/1 document every other runtime also emits.

The recorder is a GenServer rather than a struct threaded through the caller's state because executions run in per-request processes and an aggregate has to live somewhere they can all reach. Supervise it under the application tree and give it a name. Updates are casts, so a slow or dead collector adds no latency to an execution and cannot fail one. In a multi-node deployment each node keeps its own window; merging is the application's decision.

Every non-successful execution carries one of the stable categories in PraugaFlexDoc.HostExecutionObservability.reasons/0, which is why rejections and upstream failures are separable at all — the human-readable messages interpolate origins and field names, so they are unbounded and unusable as a metric label. Requests arriving without X-FlexDoc-Execute are counted by flexdoc_execute_unmarked_total and deliberately move no lifecycle metric, since they produced no validated envelope.

The report declares browser-direct-transport-mix in its gaps: a browser-direct execution never reaches this node, so the transport mix cannot be derived here. See host-execution observability for the full metric contract and the browser half of a review.

Phoenix

Phoenix routers can forward directly to the same Plug. Place the application's authentication/authorization plugs before the forward, then use the same explicit protection acknowledgement when host execution is enabled:

forward "/docs", PraugaFlexDoc.Plug,
path: "/docs",
spec_url: "/openapi.json",
title: "My API"

Because Phoenix is Plug-based, no Phoenix-specific renderer implementation is necessary. The same try_it_host_execution / host_execution_protected / host_execution options apply when using forward.

CI exercises the package through Plug.Test and byte-compares its packaged JS/CSS with the canonical renderer.