lib/apical.ex

defmodule Apical do
  @moduledoc """
  Generates a web router from an OpenAPI document.

  Building an OpenAPI-compliant Phoenix router can be as simple as:

  ```elixir
  defmodule MyRouter do
    require Apical

    Apical.router_from_file(
      "path/to/openapi.yaml",
      controller: MyProjectWeb.ApiController
    )
  end
  ```

  See https://spec.openapis.org/oas/v3.1.0 for details on how to compose an OpenAPI
  schema.

  Using the macros `router_from_string/2` or `router_from_file/2` you may generate a
  `Phoenix.Router` that corresponds to OpenAPI document.

  > ### Tip {: .tip }
  >
  > In general, using `router_from_file/2` is should be preferred, especially if you
  > must maintain multiple versions of the schema, though you may find it easier to
  > iterate using `router_from_string/2` during early development.  In that case, it
  > is possible to switch to `router_from_file/2` when you are ready to finalize your
  > API design or start versioning

  > ### Plug routers {: .info }
  >
  > `Plug`-only routers, which do not require `Phoenix`, are planned for a future release.

  The following activities are performed by the router generated by the macros:

  - Tagging inbound requests with API version
  - Constructing route and http verb matches in the router
  - Parameter operations
    - Supports:
      - Cookie parameters
      - Header parameters
      - Path parameters
      - Query parameters
    - Features:
      - Style decoding based on parameter styles (see https://spec.openapis.org/oas/v3.1.0#style-values)
      - Custom style decoding
      - Parameter marshalling (converting strings to types)
      - Parameter validation
  - Request body validation
    - content-length and content-type validation
    - matching content-type with request body plugs
    - Automatic json and `form-encoded` request body parsing
    - Parameter marshalling for `form-encoded` requests

  ### Options

  The following options are common to `router_from_string/2` and `router_from_file/2`.

  #### Global options
  - `encoding`: mimetype which describes how the schema is encoded.

    required in `router_from_string/2`, deduced from filename in `router_from_file/2`.

  - `decoders`:  A proplist of mimetype to decoders.

    If you use an encoding that isn't `application/json` or `application/yaml` you
    should provide this proplist, which, at a minimum, contains
    `[{encoding_mimetype, {module, function}}]`.  The call `module.function(string)`
    should return a map representing the OpenAPI schema, and should raise in the
    case that the content is not decodable.

  - `root`: the root path for the router.

    Defaults to `/v{major}` where `major` is the major version of the API, as declared
    under `info.version` in the schema.

  - `dump`: (For debugging), sends formatted code of the router to stdout.

    Defaults to `false`.  If set to `:all`, will also pass `dump: true` to Exonerate.

  #### Scopable options

  The following options are *scopable*.  They may be placed as top-level options
  or under the scopes (see below)

  - `controller`:  Plug module which contains code implementing the API.

    It is recommended to `use Phoenix.Controller` in this plug module, or the
    functions may or may not be targetted as expected.

    Controller modules should implement public functions corresponding to the
    `operationId` of each operation in the schema.  These functions must be
    cased in the same fashion as the `operationId`, and like all Phoenix Controller
    functions, take two arguments:

    - `conn`: the `Plug.Conn` for the request
    - `params`: a map containing the parameters for the operation.  This is
      identical to `conn.params`.

    > ### Important {: .warning }
    >
    > Unlike standard Phoenix controller functions, parameters declared in the
    > `parameters` list of the operation are made avaliable in the `params`
    > argument as well as in `conn.params`.  These parameters will overwrite
    > any fields present in body parameters that happen to have the same name.

    A single router may have its routes target more than one controller.

  - `extra_plugs`: a list of plugs to execute after the route has matched
    but before the parameter and body pipeline has been executed.

    These plugs are  defined using `{atom, [args...]}` where `args` is
    a list of plug options to be applied to the plug, or `atom` which
    is equivalent to `{atom, []}`.  These may be either a function plug
    or a module plug.

    > ### Route-level Security Plugs {: .tip }
    >
    > Route-level security checks should be performed in plugs declared in
    > `extra_plugs`, until `Apical` provides direct support for security
    > schemes.

    > ### Global plugs {: .tip }
    >
    > if you need plugs to be executed for all routes, declare those plugs
    > in the router module before the macro `Exonerate.router_from_*`.

    > ### Post-pipeline plugs {: .tip }
    >
    > if you need plugs to be executed after the parameter and body pipeline,
    > for example, for row-level security checks, declare those plugs in the
    > controller module.  Note that these plugs should be able to match on
    > the `operationId` atom using `conn.private.operation_id`.

  - `styles`: a proplist of custom styles and their corresponding parsers.

      Each parser is represented as `{module, function, [args...]}` or
      `{module, function}` which is equivalent too `{module, function []}`.

      The parsers are functions that are called as
      `module.function(string, args...)`,  and return `{:ok, value}` or
      `{:error, message}`.  The message should be a string describing the
      error.

      The following styles are supported by default and do not need to be
      included in the styles proplist:

      - `"matrix"`
      - `"label"`
      - `"simple"`
      - `"form"`
      - `"space_delimited"`
      - `"pipe_delimited"`
      - `"deep_object"`

      see https://spec.openapis.org/oas/v3.1.0#style-values for description
      of these styles.

      > ### Custom styles {: .warning }
      >
      > If you need to support a custom style, you *must* add it to the
      > `styles` proplist.

      > ### Form-exploded objects {: .error }
      >
      > Form-exploded style parameters with type `object` in their schema are
      > not supported due to ambiguity in their definition per the OpenAPI
      > specification.

  - `content_sources`: A proplist of media-types (as **string** keys) and
    functions to act as the source for request body.  These should be
    defined as `{media_type, {module, [opts...]}}`.  These opts will be
    passed into the `c:Apical.Plugs.RequestBody.Source.fetch/3`.

  - `nest_all_json`: Analogous to the option in `Plug.Parsers.JSON`, this
    option will nest all json request body payloads under the `"_json"` key.
    if this is not true, objects payloads will be merged into `conn.params`.

  #### Available scopes

  The scopes have the following precedence:

  operation_ids > tags > parameters > global

  - `operation_ids`: A keywordlist of `operationId`s (as atom keys) and options
    to target to these operations.

    The keys must be cased in the same fashion as the `operationId` in the
    schema.

  - `tags`: A keywordlist of tags (as atom keys) and options to target to those
    tags.

    The tag keys must be cased in the same fashion as their tags in the schema.

  - `parameters`: A keywordlist of parameters (as atom keys) and options to
    target to those parameters.

    The parameter keys must be cased in the same fashion (including kebab-case)
    Note that this scope may be further nested inside of `tag` and
    `operation_ids` scopes.
  """

  alias Apical.Tools

  @spec router_from_string(String.t(), Keyword.t()) :: any()
  @doc """
  Generates a web router from a String containing an OpenAPI document.

  ### Example:

  ```elixir
  defmodule MyRouter do
    require Apical

    Apical.router_from_string(
      \"""
      openapi: 3.1.0
      info:
        title: My API
        version: 1.0.0
      paths:
        "/":
          get:
            operationId: getOperation
            responses:
              "200":
                description: OK
      \""",
      controller: MyProjectWeb.ApiController,
      encoding: "application/yaml"
    )
  end
  ```

  For options see `Apical` module docs.
  """
  defmacro router_from_string(string, opts) do
    opts = Macro.expand_literals(opts, __CALLER__)

    router(string, opts)
  end

  @spec router_from_file(Path.t(), Keyword.t()) :: any()
  @doc """
  Generates a web router from a String containing an OpenAPI document.

  ### Example:

  ```elixir
  defmodule MyRouter do
    require Apical

    Apical.router_from_file(
      "path/to/openapi.yaml",
      controller: MyProjectWeb.ApiController
    )
  end
  ```

  For options see `Apical` module docs.
  """
  defmacro router_from_file(file, opts) do
    opts =
      opts
      |> Macro.expand_literals(__CALLER__)
      |> Keyword.merge(file: file)
      |> Keyword.put_new_lazy(:encoding, fn -> find_encoding(file, opts) end)

    file
    |> Macro.expand(__CALLER__)
    |> File.read!()
    |> router(opts)
  end

  defp router(string, opts) do
    string
    |> Tools.decode(opts)
    |> Apical.Phoenix.router(string, opts)
    |> Tools.maybe_dump(opts)
  end

  defp find_encoding(filename, _opts) do
    case Path.extname(filename) do
      ".json" -> "application/json"
      ".yaml" -> "application/yaml"
      _ -> raise "unsupported file extension"
    end
  end
end