lib/open_api_spex/controller_specs.ex

defmodule OpenApiSpex.ControllerSpecs do
  @moduledoc """
  Macros for defining operation specs, shared operation tags, and shared security specs
  in a Phoenix controller.

  ## Example

  Here is an example Phoenix controller that uses the ControllerSpecs Operation specs:

      defmodule MyAppWeb.UserController do
        use Phoenix.Controller
        use OpenApiSpex.ControllerSpecs

        alias MyAppWeb.Schemas.{UserParams, UserResponse}

        tags ["users"]
        security [%{}, %{"petstore_auth" => ["write:users", "read:users"]}]

        operation :update,
          summary: "Update user",
          parameters: [
            id: [
              in: :path,
              description: "User ID",
              type: :integer,
              example: 1001
            ]
          ],
          request_body: {"User params", "application/json", UserParams},
          responses: [
            ok: {"User response", "application/json", UserResponse}
          ]

        def update(conn, %{"id" => id}) do
          json(conn, %{
            data: %{
              id: id,
              name: "joe user",
              email: "joe@gmail.com"
            }
          })
        end
      end

  If you use Elixir Formatter, `:open_api_spex` can be added to the `:import_deps`
  list in the `.formatter.exs` file of your project to make parentheses of the
  macros optional.

  `.formatter.exs`:

  ```elixir
  [
    import_deps: [:open_api_spex]
  ]
  ```

  ## `parameters`

  `parameters` is a keyword list (or map) of request parameters (not body parameters). They each represent the
  OpenAPI [Parameter Object](https://spec.openapis.org/oas/v3.1.0#parameter-object).

  There is a convenient shortcut `:type` for base data types supported by open api

  ```elixir
  parameters: [
    id: [in: :query, type: :integer, required: true, description: "User ID", example: 1001]
  ]
  ```

  This is equivalent to:

  ```elixir
  parameters: [
    id: [in: :query, schema: %OpenApiSpex.Schema{type: :integer}, required: true, description: "User ID", example: 1001]
  ]
  ```

  The keyword value is the parameter name. Each value in the keyword list correlates to a field in the OpenAPI ParameterObject.

  ## `request_body`

  The `request_body` defines the OpenAPI [RequestBodyObject](https://spec.openapis.org/oas/v3.1.0#request-body-object).

  The `request_body` takes a tuple that is 2-4 elements in length. The elements of the tuple are:

  1. The RequestBody `description` field.
  2. The RequestBody `content` field, consisting of a mapping of content types to their `MediaType` objects,
     or a simple content type string (e.g., "application/json").
     ```elixir
     content: "application/json"
     ```
     Or:
     ```elixir
     content: %{"application/text" => [example: "some text!"]}
     ```
     Or:
     ```elixir
     content: %{"application/text" => [%OpenApiSpex.MediaType{example: "some text!"}]}
     ```
  3. The default schema of the RequestBody.
  4. A keyword list of options.

  ## `responses`

  The `responses` key defines the OpenAPI [Responses Object](https://spec.openapis.org/oas/v3.1.0#fixed-fields-7)

  The `responses` value is a keyword list or map that maps the HTTP status to a
  [ResponseObject](https://spec.openapis.org/oas/v3.1.0#response-object).

  If the the responses is defined using keyword list syntax,
  the HTTP status codes can be replaced with their text equivalents:

  ```elixir
  responses: [
    ok: {"User response", "application/json", MyAppWeb.Schemas.UserResponse},
    unprocessable_entity: {"Bad request parameters", "application/json", MyAppWeb.Schemas.BadRequestParameters},
    not_found: {"Not found", "application/json", MyAppWeb.Schemas.NotFound}
  ]
  ```

  The full set of atom keys are defined in `Plug.Conn.Status.code/1`.

  Alternately, the HTTP status codes can be specified directly:

  ```elixir
  responses: %{
    200 => {"User response", "application/json", MyAppWeb.Schemas.UserResponse},
    422 => {"Bad request parameters", "application/json", MyAppWeb.Schemas.BadRequestParameters},
    404 => {"Not found", "application/json", MyAppWeb.Schemas.NotFound}
  }
  ```

  The ResponseObject is represented as a tuple that is 2-4 elements in length. The elements of the tuple are:

  1. The ResponseObject `description` field.
  2. The ResponseObject `content` field, consisting of a mapping of content types to their `MediaType` objects,
     or a simple content type string (e.g., "application/json").
     ```elixir
     content: %{"application/json" => [example: "{[]}"]}
     ```
     Or:
     ```elixir
     content: "application/json"
     ```
  3. The default schema of the response body.
  4. A keyword list of options to add to the `OpenApiSpex.Response` or `OpenApiSpex.MediaType` structs
     that are generated.

  """
  alias OpenApiSpex.{Operation, OperationBuilder}

  defmacro __using__(_opts) do
    quote do
      import OpenApiSpex.ControllerSpecs

      Module.register_attribute(__MODULE__, :spec_attributes, accumulate: true)
      Module.register_attribute(__MODULE__, :shared_tags, accumulate: true)
      Module.register_attribute(__MODULE__, :shared_security, accumulate: true)

      @before_compile {OpenApiSpex.ControllerSpecs, :before_compile}

      @spec open_api_operation(atom()) :: OpenApiSpex.Operation.t()
    end
  end

  @doc """
  Defines an Operation spec for a controller action.

  ## Example

      operation :update,
        summary: "Update user",
        description: "Updates a user record from the given ID path parameter and request body parameters.",
        parameters: [
          id: [
            in: :path,
            description: "User ID",
            type: :integer,
            example: 1001
          ]
        ],
        request_body: {"User params", "application/json", UserParams},
        responses: [
          ok: {"User response", "application/json", UserResponse}
        ],
        security: [%{}, %{"petstore_auth" => ["write:pets", "read:pets"]}],
        tags: ["users"]

  ## Options

  These options correlate to the
  [Operation fields specified in the Open API spec](https://swagger.io/specification/#operation-object).
  One difference however, is that the fields defined in Open API use `camelCase` naming,
  while the fields used in `ControllerSpecs` use `snake_case` to match Elixir's convention.

  - `summary` The operation summary
  - `parameters` The endpoint's parameters. The syntax for `parameters` can take multiple forms:
    - The common form is a keyword list, where each key is the parameter name, and the value
      is a keyword list of options that correlate to the fields in an
      [Open API Parameter Object](https://swagger.io/specification/#parameter-object).
      For example:

      ```elixir
        parameters: [
          id: [
            in: :path,
            description: "User ID",
            type: :integer,
            example: 1001
          ]
        ]
      ```
    - A `parameters` list can also contain references to parameter schemas. There are two
      ways to do that:

      ```elixir
      parameters: [
        "$ref": "#/components/parameters/user_id"
        # or
        %OpenApiSpex.Reference{"$ref": "#/components/parameters/user_id"}
      ]
      ```
  - `request_body` The endpoint's request body. There are multiple ways to specify a request body:
    - A three or four-element tuple:

        ```elixir
        request_body: {
          "User update request body",
          "application/json",
          UserUpdateRequest,
          required: true
        }
        ```

      The tuple consists of the following:
      1. The description
      2. The content-type
      3. An Open API schema. This can be a schema module that implements the
         `OpenApiSpex.Schema` [behaviour](https://hexdocs.pm/elixir/Module.html#module-behaviour),
         or an `OpenApiSpex.Schema` struct.
      4. A optional keyword list of options. There is only one option available,
         and that is `required: boolean`.
  - `responses` The endpoint's responses, for each HTTP status code the endpoint may respond with.
    Multiple syntaxes are supported:
    - A common syntax is a keyword list, where each key is the textual name of an HTTP status code.
      For example:

        ```elixir
        [
          ok: {"User response", "application/json", User},
          not_found: {"User not found", "application/json", NotFound}
        ]
        ```

      The list of names and their code mappings is defined in
      `Plug.Conn.Status.code/1`.

    - If a map is used, the keys can either be the same atom keys used
      in the keyword syntax (`%{ok: ...}`), or they can be integers representing
      the HTTP status code directly:

        ```elixir
        responses: %{
          200 => {"User response", "application/json", User},
          404 => {"User not found", "application/json", NotFound}
        }
        ```

    - Each response can be a three-element tuple:

        ```elixir
        responses: [
          ok: {"User response", "application/json", User}
          # Or
          ok: {"User response", "application/json", %OpenApiSpex.Schema{...}}
        ]
        ```

      This tuple consists of:
      1. A Description string
      2. A content-type string
      3. An Open API schema. This can be a schema module that implements the
         `OpenApiSpex.Schema` [behaviour](https://hexdocs.pm/elixir/Module.html#module-behaviour),
         or an `OpenApiSpex.Schema` struct.
      4. An optional `Keyword` list with the following optional key/values:
        a. example: an example string
        b. examples: a list of example strings
        c. headers: a `Map` with string keys defining the header name and
           an `OpenApiSpex.Header` struct as a value.

    - If the response represents an empty response, the definition value can
      be a single string representing the response description. For example:

        ```elixir
        responses: [
          no_content: "Empty response"
        ]
        ```

    - A response can also be defined as an `OpenApiSpex.RequestBody` struct. In fact, all
      response body syntaxes resolve to this struct.

  - Additional fields: There are other Operation fields that can be specified that are not
    described here. See `OpenApiSpex.Operation` for all the fields.
  """
  @spec operation(action :: atom, spec :: map) :: any
  defmacro operation(action, spec) do
    quote do
      @spec_attributes {unquote(action), operation_spec(__MODULE__, unquote(action), unquote(spec))}

      def open_api_operation(unquote(action)) do
        @spec_attributes[unquote(action)]
      end
    end
  end

  @doc """
  Defines a list of tags that all operations in a controller will share.

  ## Example

      tags ["users"]

  All operations defined in the controller will inherit the tags specified
  by this call. If an operation defines its own tags, the tags in this call
  will be appended to the operation's tags.
  """
  @spec tags(tags :: [String.t()]) :: any
  defmacro tags(tags) do
    quote do
      @shared_tags unquote(tags)
    end
  end

  @doc """
  Defines security requirements shared by all operations defined in a controller.

  See [Security Requirement Object spec](https://swagger.io/specification/#securityRequirementObject)
  and `OpenApiSpex.SecurityRequirement` for more information.
  """
  @spec security([%{required(String.t()) => [String.t()]}]) :: any
  defmacro security(requirements) do
    quote do
      @shared_security unquote(requirements)
    end
  end

  @doc false
  defmacro before_compile(_env) do
    quote do
      def open_api_operation(action) do
        module_name = __MODULE__ |> to_string() |> String.replace_leading("Elixir.", "")
        IO.warn("No operation spec defined for controller action #{module_name}.#{action}")
      end

      def shared_tags, do: @shared_tags

      def shared_security, do: @shared_security
    end
  end

  @doc """
  Define an Operation for a controller action.

  See `OpenApiSpex.ControllerSpecs` for usage and examples.
  """
  def operation_spec(_module, _action, nil = _spec), do: nil
  def operation_spec(_module, _action, false = _spec), do: nil

  def operation_spec(module, action, spec) do
    spec = Map.new(spec)
    shared_tags = Module.get_attribute(module, :shared_tags, []) |> List.flatten()

    security =
      case Module.get_attribute(module, :shared_security) do
        [] -> nil
        shared_security -> List.flatten(shared_security)
      end

    extensions =
      spec
      |> Enum.filter(fn {key, _val} -> is_atom(key) && String.starts_with?(to_string(key), "x-") end)
      |> Map.new(fn {key, value} -> {to_string(key), value} end)

    %Operation{
      callbacks: Map.get(spec, :callbacks, %{}),
      description: Map.get(spec, :description),
      deprecated: Map.get(spec, :deprecated),
      externalDocs: OperationBuilder.build_external_docs(spec),
      operationId: OperationBuilder.build_operation_id(spec, module, action),
      parameters: OperationBuilder.build_parameters(spec),
      requestBody: OperationBuilder.build_request_body(spec),
      responses: OperationBuilder.build_responses(spec),
      security: OperationBuilder.build_security(spec, %{security: security}),
      summary: Map.get(spec, :summary),
      tags: OperationBuilder.build_tags(spec, %{tags: shared_tags}),
      extensions: extensions
    }
  end
end