lib/open_api/renderer.ex

defmodule OpenAPI.Renderer do
  @moduledoc """
  Phase three of code generation

  The **render** phase begins with operation and schema structs created during the **process**
  phase. It uses this data to construct and save files containing Elixir source code. Most of
  the work done by this phase involves the manipulation of Elixir ASTs.
  """
  alias OpenAPI.Processor.Operation
  alias OpenAPI.Processor.Schema
  alias OpenAPI.Processor.Type
  alias OpenAPI.Renderer.File
  alias OpenAPI.Renderer.State

  @spec run(OpenAPI.State.t()) :: OpenAPI.State.t()
  def run(%OpenAPI.State{} = state) do
    %State{files: files_by_module} =
      state
      |> State.new()
      |> collect_files()
      |> render_files()

    %OpenAPI.State{state | files: Map.values(files_by_module)}
  end

  #
  # Integration
  #

  defmacro __using__(_opts) do
    quote do
      @behaviour OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate format(state, file), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate location(state, file), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate render(state, file), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate render_default_client(state, file), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate render_moduledoc(state, file), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate render_operations(state, file), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate render_operation(state, operation), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate render_operation_doc(state, operation), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate render_operation_function(state, operation), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate render_operation_spec(state, operation), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate render_schema(state, file), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate render_schema_field_function(state, schemas), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate render_schema_struct(state, schemas), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate render_schema_types(state, schemas), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate render_type(state, type), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate render_using(state, file), to: OpenAPI.Renderer

      @impl OpenAPI.Renderer
      defdelegate write(state, file), to: OpenAPI.Renderer

      defoverridable format: 2,
                     location: 2,
                     render: 2,
                     render_default_client: 2,
                     render_moduledoc: 2,
                     render_operations: 2,
                     render_operation: 2,
                     render_operation_doc: 2,
                     render_operation_function: 2,
                     render_operation_spec: 2,
                     render_schema: 2,
                     render_schema_field_function: 2,
                     render_schema_struct: 2,
                     render_schema_types: 2,
                     render_type: 2,
                     render_using: 2,
                     write: 2
    end
  end

  #
  # Callbacks
  #

  @optional_callbacks format: 2,
                      location: 2,
                      render: 2,
                      render_default_client: 2,
                      render_moduledoc: 2,
                      render_operations: 2,
                      render_operation: 2,
                      render_operation_doc: 2,
                      render_operation_function: 2,
                      render_operation_spec: 2,
                      render_schema: 2,
                      render_schema_field_function: 2,
                      render_schema_struct: 2,
                      render_schema_types: 2,
                      render_type: 2,
                      render_using: 2,
                      write: 2

  @doc """
  Convert the Abstract Syntax Tree (AST) form of the file into formatted code

  This callback can expect a `t:OpenAPI.Renderer.File.t/0` struct with the completed contents of
  the file included in the `ast` field. Nodes of the AST may include optional formatting metadata
  (ex. `delimiter`, `indentation`, or `end_of_expression`). It is recommended that the formatter
  adhere to the standard configuration of the default Mix formatter (for example, formatting to a
  line width of 98) in order to avoid a large amount of changes should someone run `mix format` on
  the generated code.

  The return value of the callback can be `iodata` (strings do not need to be concatenated), and
  it will be stored in the `contents` field of the file.

  See `OpenAPI.Renderer.Util.format/2` for the default implementation.
  """
  @callback format(state :: State.t(), file :: File.t()) :: iodata

  @doc """
  Choose the filesystem location for a rendered file to be written

  See `OpenAPI.Renderer.Module.filename/2` for the default implementation.
  """
  @callback location(state :: State.t(), file :: File.t()) :: String.t()

  @doc """
  Create the contents of a file in quoted Abstract Syntax Tree (AST) form

  This callback is the primary function called to render a file. The default implementation calls
  several other callbacks, each of which my be overridden separately:

    * `c:OpenAPI.Renderer.render_moduledoc/2`
    * `c:OpenAPI.Renderer.render_using/2`
    * `c:OpenAPI.Renderer.render_default_client/2`
    * `c:OpenAPI.Renderer.render_schema/2`
    * `c:OpenAPI.Renderer.render_operations/2`

  See `OpenAPI.Renderer.Module.render/2` for the default implementation.
  """
  @callback render(state :: State.t(), file :: File.t()) :: Macro.t()

  @doc """
  Render the `@default_client` module attribute in an operation module

  When using the default operation function renderer, every operation function includes a line:

      client = opts[:client] || @default_client

  This allows callers to override the client implementation without having to pass the default
  module in as an argument. This callback renders the definition of the `@default_client` module
  attribute, effectively choosing which module will be called for every operation.

  See `OpenAPI.Renderer.Module.render_default_client/2` for the default implementation.
  """
  @callback render_default_client(state :: State.t(), file :: File.t()) :: Macro.t()

  @doc """
  Render the `@moduledoc` portion of the file

  Users of a client library may lean on this documentation to find the operation or schema they
  need. While the default implementation presents a fairly basic line of documentation depending
  on whether the file contains operations or a schema, custom implementations of this callback
  could provide rich and helpful instructions to consumers.

  See `OpenAPI.Renderer.Module.render_moduledoc/2` for the default implementation.
  """
  @callback render_moduledoc(state :: State.t(), file :: File.t()) :: Macro.t()

  @doc """
  Render the associated types, docstring, typespec, and function for all operations

  This is the primary function called to render all operations in a file. The default
  implementation calls several other callbacks (all named `render_operation*`) which can be
  overridden individually.

  See `OpenAPI.Renderer.Operation.render_all/2` for the default implementation.
  """
  @callback render_operations(state :: State.t(), file :: File.t()) :: Macro.t()

  @doc """
  Render the associated types, docstring, typespec, and function for a single operation

  The default implementation of this function calls several other callbacks (all named
  `render_operation_*`) which can be overridden individually.

  See `OpenAPI.Renderer.Operation.render/2` for the default implementation.
  """
  @callback render_operation(state :: State.t(), operation :: Operation.t()) :: Macro.t()

  @doc """
  Render the `@doc` portion of an operation function

  See `OpenAPI.Renderer.Operation.render_doc/2` for the default implementation.
  """
  @callback render_operation_doc(state :: State.t(), operation :: Operation.t()) :: Macro.t()

  @doc """
  Render the main function portion of an operation

  See `OpenAPI.Renderer.Operation.render_function/2` for the default implementation.
  """
  @callback render_operation_function(state :: State.t(), operation :: Operation.t()) :: Macro.t()

  @doc """
  Render the `@spec` portion of an operation function

  See `OpenAPI.Renderer.Operation.render_spec/2` for the default implementation.
  """
  @callback render_operation_spec(state :: State.t(), operation :: Operation.t()) :: Macro.t()

  @doc """
  Render the types, struct, and field function for schemas not related to an operation

  This is the primary function called to render schemas. The default implementation calls several
  other callbacks (all named `render_schema_*`) which can be overridden individually.

  See `OpenAPI.Renderer.Schema.render/2` for the default implementation.
  """
  @callback render_schema(state :: State.t(), file :: File.t()) :: Macro.t()

  @doc """
  Render a function `__fields__/1` that return a keyword list of schema fields and their types

  See `OpenAPI.Renderer.Schema.render_field_function/2` for the default implementation.
  """
  @callback render_schema_field_function(state :: State.t(), schemas :: [Schema.t()]) :: Macro.t()

  @doc """
  Render the `defstruct` call for the schema types contained in the file

  See `OpenAPI.Renderer.Schema.render_struct/2` for the default implementation.
  """
  @callback render_schema_struct(state :: State.t(), schemas :: [Schema.t()]) :: Macro.t()

  @doc """
  Render the typespecs for schema types contained in the file

  See `OpenAPI.Renderer.Schema.render_types/2` for the default implementation.
  """
  @callback render_schema_types(state :: State.t(), schemas :: [Schema.t()]) :: Macro.t()

  @doc """
  Render the typespec for type

  See `OpenAPI.Renderer.Util.to_type/2` for the default implementation.
  """
  @callback render_type(state :: State.t(), Type.t() | {module, atom}) :: Macro.t()

  @doc """
  Render one or more `use` statements to include in the file

  Another route for customization of the outputted code is via meta-programming. This callback
  enables library authors to `use` any module they like at the top of files that contain schemas,
  operations, or both. The referenced modules can then perform additional compile-time changes.

  See `OpenAPI.Renderer.Module.render_using/2` for the default implementation.
  """
  @callback render_using(state :: State.t(), file :: File.t()) :: Macro.t()

  @doc """
  Write a rendered file to the filesystem

  This callback can expect to receive a `t:OpenAPI.Renderer.File.t/0` struct with formatted file
  contents expressed as `iodata` in the `contents` field. It should write the file to the
  filesystem at the appropriate location included in the `location` field. While the return value
  is irrelevant, a simple `:ok` will suffice.

  See `OpenAPI.Renderer.Util.write/2` for the default implementation.
  """
  @callback write(state :: State.t(), file :: File.t()) :: :ok

  #
  # Default Implementations
  #

  @behaviour __MODULE__

  @doc false
  @impl __MODULE__
  defdelegate format(state, file), to: OpenAPI.Renderer.Util

  @doc false
  @impl __MODULE__
  defdelegate location(state, file), to: OpenAPI.Renderer.Module, as: :filename

  @doc false
  @impl __MODULE__
  defdelegate render(state, file), to: OpenAPI.Renderer.Module

  @doc false
  @impl __MODULE__
  defdelegate render_default_client(state, file), to: OpenAPI.Renderer.Module

  @doc false
  @impl __MODULE__
  defdelegate render_moduledoc(state, file), to: OpenAPI.Renderer.Module

  @doc false
  @impl __MODULE__
  defdelegate render_operations(state, file), to: OpenAPI.Renderer.Operation, as: :render_all

  @doc false
  @impl __MODULE__
  defdelegate render_operation(state, operation), to: OpenAPI.Renderer.Operation, as: :render

  @doc false
  @impl __MODULE__
  defdelegate render_operation_doc(state, operation),
    to: OpenAPI.Renderer.Operation,
    as: :render_doc

  @doc false
  @impl __MODULE__
  defdelegate render_operation_function(state, operation),
    to: OpenAPI.Renderer.Operation,
    as: :render_function

  @doc false
  @impl __MODULE__
  defdelegate render_operation_spec(state, operation),
    to: OpenAPI.Renderer.Operation,
    as: :render_spec

  @doc false
  @impl __MODULE__
  defdelegate render_schema(state, file), to: OpenAPI.Renderer.Schema, as: :render

  @doc false
  @impl __MODULE__
  defdelegate render_schema_field_function(state, schemas),
    to: OpenAPI.Renderer.Schema,
    as: :render_field_function

  @doc false
  @impl __MODULE__
  defdelegate render_schema_struct(state, schemas),
    to: OpenAPI.Renderer.Schema,
    as: :render_struct

  @doc false
  @impl __MODULE__
  defdelegate render_schema_types(state, schemas), to: OpenAPI.Renderer.Schema, as: :render_types

  @doc false
  @impl __MODULE__
  defdelegate render_type(state, type), to: OpenAPI.Renderer.Util, as: :to_type

  @doc false
  @impl __MODULE__
  defdelegate render_using(state, file), to: OpenAPI.Renderer.Module

  @doc false
  @impl __MODULE__
  defdelegate write(state, file), to: OpenAPI.Renderer.Util

  #
  # Helpers
  #

  @spec collect_files(State.t()) :: State.t()
  defp collect_files(state) do
    %State{operations: operations, schemas: schemas} = state

    for item <- operations ++ Map.values(schemas), reduce: state do
      state -> collect_file(state, item)
    end
  end

  @spec collect_file(State.t(), Operation.t()) :: State.t()
  @spec collect_file(State.t(), Schema.t()) :: State.t()
  defp collect_file(state, %Operation{module_name: module} = operation) do
    State.update_files(state, module, operation)
  end

  defp collect_file(state, %Schema{module_name: nil}) do
    state
  end

  defp collect_file(state, %Schema{module_name: module} = schema) do
    State.update_files(state, module, schema)
  end

  @spec render_files(State.t()) :: State.t()
  defp render_files(state) do
    %State{files: files_by_module, implementation: implementation} = state

    for {module, file} <- files_by_module, reduce: state do
      %State{files: files_by_module} = state ->
        file =
          file
          |> then(&%{&1 | ast: implementation.render(state, &1)})
          |> then(&%{&1 | contents: implementation.format(state, &1)})
          |> then(&%{&1 | location: implementation.location(state, &1)})

        if file.contents != "" do
          implementation.write(state, file)
          %State{state | files: Map.put(files_by_module, module, file)}
        else
          state
        end
    end
  end
end