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.Renderer.File
  alias OpenAPI.Renderer.State

  @spec run(OpenAPI.State.t()) :: OpenAPI.State.t()
  def run(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

      defdelegate format(state, file), to: OpenAPI.Renderer
      defdelegate location(state, file), to: OpenAPI.Renderer
      defdelegate render(state, file), to: OpenAPI.Renderer
      defdelegate render_default_client(state, file), to: OpenAPI.Renderer
      defdelegate render_moduledoc(state, file), to: OpenAPI.Renderer
      defdelegate render_operations(state, file), to: OpenAPI.Renderer
      defdelegate render_operation(state, operation), to: OpenAPI.Renderer
      defdelegate render_operation_doc(state, operation), to: OpenAPI.Renderer
      defdelegate render_operation_function(state, operation), to: OpenAPI.Renderer
      defdelegate render_operation_spec(state, operation), to: OpenAPI.Renderer
      defdelegate render_schema(state, file), to: OpenAPI.Renderer
      defdelegate render_schema_field_function(state, schemas), to: OpenAPI.Renderer
      defdelegate render_schema_struct(state, schemas), to: OpenAPI.Renderer
      defdelegate render_schema_types(state, schemas), to: OpenAPI.Renderer
      defdelegate render_using(state, file), to: 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_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_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 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
  #

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  @doc false
  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(&%File{&1 | ast: implementation.render(state, &1)})
          |> then(&%File{&1 | contents: implementation.format(state, &1)})
          |> then(&%File{&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