{"id":47633178,"url":"https://github.com/andreashasse/phoenix_spectral","last_synced_at":"2026-04-18T14:09:45.020Z","repository":{"id":339526673,"uuid":"1158736470","full_name":"andreashasse/phoenix_spectral","owner":"andreashasse","description":"FastAPI for Phoenix","archived":false,"fork":false,"pushed_at":"2026-04-01T09:47:53.000Z","size":128,"stargazers_count":5,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-01T11:35:09.156Z","etag":null,"topics":["api","elixir","openapi","phoenix","swagger"],"latest_commit_sha":null,"homepage":"https://hexdocs.pm/phoenix_spectral/","language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/andreashasse.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-15T20:51:56.000Z","updated_at":"2026-04-01T09:47:57.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/andreashasse/phoenix_spectral","commit_stats":null,"previous_names":["andreashasse/phoenix_spec","andreashasse/phoenix_spectral"],"tags_count":5,"template":false,"template_full_name":null,"purl":"pkg:github/andreashasse/phoenix_spectral","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/andreashasse%2Fphoenix_spectral","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/andreashasse%2Fphoenix_spectral/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/andreashasse%2Fphoenix_spectral/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/andreashasse%2Fphoenix_spectral/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/andreashasse","download_url":"https://codeload.github.com/andreashasse/phoenix_spectral/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/andreashasse%2Fphoenix_spectral/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31293128,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-01T21:15:39.731Z","status":"ssl_error","status_checked_at":"2026-04-01T21:15:34.046Z","response_time":53,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api","elixir","openapi","phoenix","swagger"],"created_at":"2026-04-01T23:53:09.196Z","updated_at":"2026-04-01T23:53:09.707Z","avatar_url":"https://github.com/andreashasse.png","language":"Elixir","funding_links":[],"categories":[],"sub_categories":[],"readme":"# PhoenixSpectral\n\nPhoenixSpectral integrates [Spectral](https://github.com/andreashasse/spectral) with Phoenix, making controller typespecs the single source of truth for OpenAPI 3.1 spec generation and request/response validation. Define your types once — PhoenixSpectral derives the API docs and enforces them at runtime.\n\n## Installation\n\nAdd `phoenix_spectral` to your dependencies in `mix.exs`:\n\n```elixir\ndef deps do\n  [\n    {:phoenix_spectral, \"~\u003e 0.3.2\"}\n  ]\nend\n```\n\n## Usage\n\n### Step 1: Define typed structs with Spectral\n\n[Spectral](https://github.com/andreashasse/spectral) is an Elixir library that validates, decodes, and encodes data according to your `@type` definitions. Add `use Spectral` to a module and your types become the schema — PhoenixSpectral reads them to validate requests, decode inputs, encode responses, and generate the OpenAPI spec.\n\n```elixir\ndefmodule MyApp.User do\n  use Spectral\n\n  defstruct [:id, :name, :email]\n\n  spectral(title: \"User\", description: \"A user resource\")\n  @type t :: %__MODULE__{\n    id: integer(),\n    name: String.t(),\n    email: String.t()\n  }\nend\n\ndefmodule MyApp.Error do\n  use Spectral\n\n  defstruct [:message]\n\n  spectral(title: \"Error\")\n  @type t :: %__MODULE__{message: String.t()}\nend\n```\n\n### Step 2: Create a typed controller\n\n`use PhoenixSpectral.Controller` replaces the standard Phoenix `action(conn, params)` convention with five typed arguments. The four request inputs are kept separate rather than merged into one `params` map: the body can be a typed struct, which cannot be merged into a flat map alongside path args and query params without losing its type, and the OpenAPI generator needs to know where each field comes from — path, query, header, or body — to produce a correct spec.\n\n```elixir\n@spec update(Plug.Conn.t(), %{id: integer()}, %{}, %{}, MyApp.UserInput.t()) ::\n        {200, %{}, MyApp.User.t()}\n        | {404, %{}, MyApp.Error.t()}\ndef update(_conn, %{id: id}, _query_params, _headers, user_input), do: ...\n```\n\n- **`conn`** (`Plug.Conn.t()`) — the Plug connection, for out-of-band context (`conn.assigns`, `conn.remote_ip`, etc.)\n- **`path_args`** (map, e.g. `%{id: integer()}`) — path parameters declared in the router, decoded from strings to the types declared in the spec\n- **`query_params`** (map) — query string parameters, decoded to typed values; required keys use atom syntax (`key: type`), optional keys use arrow syntax (`optional(key) =\u003e type`)\n- **`headers`** (map) — request headers, decoded from binary strings to typed values; required keys use atom syntax (`key: type`), optional keys use arrow syntax (`optional(key) =\u003e type`)\n- **`body`** (any Elixir type (e.g., a struct), or `nil`) — decoded and validated request body, or `nil` for requests without a body\n\n\u003e **Note:** Use `conn` only for context that isn't already captured in the typed arguments — primarily `conn.assigns` (auth data from upstream plugs), `conn.remote_ip`, `conn.host`, or `conn.method`. Do not read `conn.path_params`, `conn.query_params`, `conn.req_headers`, or `conn.body_params` directly; use the decoded and validated arguments instead.\n\nActions return `{status_code, response_headers, response_body}`. Union return types produce multiple OpenAPI response entries.\n\nUse the `spectral/1` macro to annotate actions with OpenAPI metadata such as `summary` and `description`:\n\n```elixir\ndefmodule MyAppWeb.UserController do\n  use PhoenixSpectral.Controller, formats: [:json]  # opts forwarded to use Phoenix.Controller\n\n  spectral(summary: \"Get user\", description: \"Returns a user by ID\")\n  @spec show(Plug.Conn.t(), %{id: integer()}, %{}, %{}, nil) ::\n          {200, %{}, MyApp.User.t()}\n          | {404, %{}, MyApp.Error.t()}\n  def show(_conn, %{id: id}, _query, _headers, _body) do\n    case MyApp.Users.get(id) do\n      {:ok, user} -\u003e {200, %{}, user}\n      :not_found -\u003e {404, %{}, %MyApp.Error{message: \"User not found\"}}\n    end\n  end\n\n  spectral(summary: \"Create user\")\n  @spec create(Plug.Conn.t(), %{}, %{}, %{}, MyApp.User.t()) :: {201, %{}, MyApp.User.t()}\n  def create(_conn, _path_args, _query, _headers, body) do\n    {201, %{}, MyApp.Users.insert!(body)}\n  end\nend\n```\n\n#### Parameter descriptions\n\nTo add a description to a path or header parameter in the OpenAPI output, define a named type alias and annotate it with `spectral`:\n\n```elixir\nspectral(description: \"The user's unique identifier\")\n@type user_id :: integer()\n\n@spec show(Plug.Conn.t(), %{id: user_id()}, %{}, %{}, nil) ::\n        {200, %{}, MyApp.User.t()}\n        | {404, %{}, MyApp.Error.t()}\ndef show(_conn, %{id: id}, _query, _headers, _body), do: ...\n```\n\n#### Typed response headers\n\nResponse headers are declared in the return type map:\n\n```elixir\n@spec show(Plug.Conn.t(), %{id: integer()}, %{}, %{}, nil) ::\n        {200, %{\"x-request-id\": String.t()}, MyApp.User.t()}\ndef show(_conn, %{id: id}, _query, _headers, _body) do\n  {200, %{\"x-request-id\": \"abc123\"}, MyApp.Users.get!(id)}\nend\n```\n\n### Step 3: Serve the OpenAPI spec\n\n```elixir\ndefmodule MyAppWeb.OpenAPIController do\n  use PhoenixSpectral.OpenAPIController,\n    router: MyAppWeb.Router,\n    title: \"My API\",\n    version: \"1.0.0\"\nend\n```\n\nAdd routes in your router:\n\n```elixir\nscope \"/api\" do\n  get \"/users/:id\", MyAppWeb.UserController, :show\n  post \"/users\", MyAppWeb.UserController, :create\n  get \"/openapi\", MyAppWeb.OpenAPIController, :show\n  get \"/swagger\", MyAppWeb.OpenAPIController, :swagger\nend\n```\n\n`GET /openapi` returns the OpenAPI JSON spec. `GET /swagger` serves a Swagger UI page.\n\n#### OpenAPIController options\n\n| Option | Required | Description |\n|--------|----------|-------------|\n| `:router` | yes | Your Phoenix router module |\n| `:title` | yes | API title |\n| `:version` | yes | API version string |\n| `:summary` | no | Short one-line summary |\n| `:description` | no | Longer description |\n| `:terms_of_service` | no | URL to terms of service |\n| `:contact` | no | Map with `:name`, `:url`, `:email` |\n| `:license` | no | Map with `:name` and optional `:url`, `:identifier` |\n| `:servers` | no | List of maps with `:url` and optional `:description` |\n| `:openapi_url` | no | URL path for the JSON spec, used by Swagger UI. Defaults to the path of this controller's `:show` route as declared in the router (scope prefixes included). Set explicitly to use a different path. |\n| `:cache` | no | Cache the generated JSON in `:persistent_term` (default: `false`) |\n\n## Streaming and raw responses\n\nAn action can return a `Plug.Conn` directly instead of `{status, headers, body}`. This enables `send_file/3`, `send_chunked/2`, and any other conn-based response mechanism:\n\n```elixir\n@spec download(Plug.Conn.t(), %{id: String.t()}, %{}, %{}, nil) :: {200, %{}, nil}\ndef download(conn, %{id: id}, _query, _headers, _body) do\n  path = MyApp.Files.path_for(id)\n  conn\n  |\u003e put_resp_content_type(\"application/octet-stream\")\n  |\u003e send_file(200, path)\nend\n```\n\n**When a conn is returned, PhoenixSpectral passes it through without schema validation.** The typespec still documents the endpoint for the OpenAPI spec, but the actual response is your responsibility.\n\n## Request/Response Behavior\n\n- **Invalid requests** (type mismatch, missing required fields) return `400 Bad Request` with a JSON error body listing the validation errors\n- **Response encoding failures** return `500 Internal Server Error` and log the error\n- **Missing or malformed typespecs** raise at runtime — actions without `@spec` crash on dispatch; malformed specs crash on spec generation\n- Only routes whose controllers `use PhoenixSpectral.Controller` appear in the generated OpenAPI spec; standard Phoenix controllers are ignored\n\n## Example\n\nThe [`example/`](https://github.com/andreashasse/phoenix_spectral/tree/main/example) directory contains a complete runnable Phoenix app demonstrating a CRUD user API with path parameters, typed request headers, union return types, and an OpenAPI/Swagger UI endpoint. To run it:\n\n```bash\ncd example\nmix deps.get\nmake integration-test   # starts server, runs curl checks, stops server\n```\n\n## Configuration\n\nPhoenixSpectral delegates encoding, decoding, and schema generation to [Spectral](https://github.com/andreashasse/spectral) / [spectra](https://github.com/andreashasse/spectra). Configure them directly in `config/config.exs` (or `config/runtime.exs`).\n\n### Custom codecs\n\nSpectral ships codecs for `DateTime`, `Date`, and `MapSet` that are not active by default. Register them — and any application-level custom codecs — under the `:spectra` application:\n\n```elixir\n# config/config.exs\nconfig :spectra, :codecs, %{\n  {DateTime, {:type, :t, 0}} =\u003e Spectral.Codec.DateTime,\n  {Date,     {:type, :t, 0}} =\u003e Spectral.Codec.Date,\n  {MapSet,   {:type, :t, 1}} =\u003e Spectral.Codec.MapSet\n}\n```\n\nThe key is `{ModuleOwningType, {:type, type_name, arity}}`. See the [Spectral codec guide](https://github.com/andreashasse/spectral) for writing your own codecs with `use Spectral.Codec`.\n\n### Production: enable the module types cache\n\nBy default, spectra extracts type info from BEAM metadata on every decode/encode call. In production, enable persistent-term caching to avoid that overhead:\n\n```elixir\n# config/prod.exs\nconfig :spectra, :use_module_types_cache, true\n```\n\nThis stores `__spectra_type_info__/0` results in `:persistent_term` after the first call. Safe whenever modules are not hot-reloaded (i.e., in Mix releases). Clear manually with `spectra_module_types:clear(Module)` if needed.\n\n### Unicode validation\n\nspectra skips Unicode validation of list-based strings by default. Enable it when strict validation matters:\n\n```elixir\nconfig :spectra, :check_unicode, true\n```\n\n## Design\n\n- **Typespecs are the single source of truth** — no separate schema definitions; `@spec` drives both docs and validation\n- **Action convention** — `(conn, path_args, query_params, headers, body)` → `{status, headers, body}`; union return types produce multiple OpenAPI response entries\n- **Crash on bad code, error on bad user input** — malformed typespecs raise; invalid requests return 400, encoding failures return 500\n- **Automatic encoding/decoding** — Spectral handles struct serialization\n- **Optional caching** — via `persistent_term` for production performance\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fandreashasse%2Fphoenix_spectral","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fandreashasse%2Fphoenix_spectral","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fandreashasse%2Fphoenix_spectral/lists"}