lib/open_api/renderer/module.ex

defmodule OpenAPI.Renderer.Module do
  @moduledoc """
  Default implementation for callbacks related to rendering operation and schema modules

  This module contains the default implementations for:

    * `c:OpenAPI.Renderer.location/2`
    * `c:OpenAPI.Renderer.render/2`
    * `c:OpenAPI.Renderer.render_default_client/2`
    * `c:OpenAPI.Renderer.render_moduledoc/2`
    * `c:OpenAPI.Renderer.render_using/2`

  These focus on portions of modules that may appear in both schema and operation modules.

  ## Configuration

  All configuration offered by the functions in this module lives under the `output` key of the
  active configuration profile. For example (default values shown):

      # config/config.exs

      config :oapi_generator, default: [
        output: [
          base_module: nil,
          default_client: Client,
          location: "",
          operation_subdirectory: "",
          operation_use: nil,
          schema_subdirectory: "",
          schema_use: nil
        ]
      ]
  """
  alias OpenAPI.Renderer.File
  alias OpenAPI.Renderer.State
  alias OpenAPI.Renderer.Util

  @doc """
  Choose the filesystem location for the given file based on its module name and contents

  Default implementation of `c:OpenAPI.Renderer.location/2`.

  If the file does not contain any operations, the file name is chosen as the concatenation of:

  * The base `location` set in the `output` configuration,
  * The `schema_subdirectory` set in the `output` configuration, and
  * The underscored module name (ex. `Some.Schema` becomes `some/schema.ex`)

  ## Configuration

  Use `output.location` to set the directory of all outputted files (ex. `lib`). Then, optionally
  split operations and schemas into separate directories using `output.operation_subdirectory`
  and `output.schema_subdirectory`. This can be useful to show which modules are generated vs.
  those that are written by hand.

      config :oapi_generator, default: [
        output: [
          location: "lib",
          operation_subdirectory: "operations",
          schema_subdirectory: "schemas"
        ]
      ]

  With this configuration, an schema module named `My.ExampleSchema` would be output to
  `lib/schemas/my/example_schema.ex`.
  """
  @spec filename(State.t(), File.t()) :: String.t()
  def filename(state, file)

  def filename(state, %File{operations: []} = file) do
    %File{module: module} = file
    config = config(state)

    base_location = Keyword.get(config, :location, "")
    schema_subdirectory = Keyword.get(config, :schema_subdirectory, "")
    file_location = Macro.underscore(module) <> ".ex"

    Path.join([
      base_location,
      schema_subdirectory,
      file_location
    ])
  end

  def filename(state, file) do
    %File{module: module} = file
    config = config(state)

    base_location = Keyword.get(config, :location, "")
    operation_subdirectory = Keyword.get(config, :operation_subdirectory, "")
    file_location = Macro.underscore(module) <> ".ex"

    Path.join([
      base_location,
      operation_subdirectory,
      file_location
    ])
  end

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

  Default implementation of `c:OpenAPI.Renderer.render/2`.

  This callback is the primary function called to render a file. It makes use of several other
  callbacks of the renderer (in this order), 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`

  Besides concatenating the results of these functions, this function also writes the `defmodule`
  call itself.

  ## Configuration

  Use `output.base_module` to determine a prefix to the name of all modules created by the
  generator. For example, given the following configuration:

      config :oapi_generator, default: [
        output: [
          base_module: MyClientLibrary
        ]
      ]

  A file with module name `MySchema` would be output as `MyClientLibrary.MySchema`. Usually, this
  configuration contains the name of your library's root module.
  """
  @spec render(State.t(), File.t()) :: Macro.t()
  def render(state, file) do
    %State{implementation: implementation} = state
    %File{module: file_module} = file

    module_name =
      Module.concat([
        config(state)[:base_module],
        file_module
      ])

    operations = implementation.render_operations(state, file)
    schema = implementation.render_schema(state, file)

    if length(schema) > 0 or length(operations) > 0 do
      moduledoc = implementation.render_moduledoc(state, file)
      using = implementation.render_using(state, file)
      default_client = implementation.render_default_client(state, file)

      header =
        [moduledoc, using]
        |> Util.clean_list()
        |> Util.put_newlines()

      module_contents =
        [header, default_client, operations, schema]
        |> Util.clean_list()

      quote do
        defmodule unquote(module_name) do
          (unquote_splicing(module_contents))
        end
      end
    else
      nil
    end
  end

  @doc """
  Construct the `@moduledoc` portion of the file based on the file contents

  Default implementation of `c:OpenAPI.Renderer.render_moduledoc/2`.

  This function provides a basic moduledoc for each file. If the file contains only schemas, then
  the moduledoc focuses on the structs and types it provides:

  > Provides struct and types for a MySchema

  If the file contains operations, it focuses on those:

  > Provides API endpoints related to MyOperations

  """
  @spec render_moduledoc(State.t(), File.t()) :: Macro.t()
  def render_moduledoc(state, file)

  def render_moduledoc(_state, %File{operations: []} = file) do
    %File{module: module, schemas: schemas} = file
    moduledoc = "Provides struct and #{plural(schemas, "type")} for a #{inspect(module)}\n"
    quote do: @moduledoc(unquote(moduledoc))
  end

  def render_moduledoc(_state, file) do
    %File{module: module, operations: operations} = file
    topic = Macro.underscore(module) |> String.replace("_", " ")
    moduledoc = "Provides API #{plural(operations, "endpoint")} related to #{topic}\n"
    quote do: @moduledoc(unquote(moduledoc))
  end

  @doc """
  Construct any `use` statements that should be included in the file

  Default implementation of `c:OpenAPI.Renderer.render_using/2`.

  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.

  ## Configuration

  Use `output.operation_use` to include a `use` statement at the top of each file that contains
  any operations, and `output.schema_use` to similarly include a `use` statement at the top of
  files containing a schema. Keep in mind that some files may contain both operations and a
  schema.

      config :oapi_generator, default: [
        output: [
          schema_use: MyLib.Schema
        ]
      ]

  This will result in a statement `use MyLib.Schema` (note that the `output.base_module`
  configuration is not used in this context).
  """
  @spec render_using(State.t(), File.t()) :: Macro.t()
  def render_using(state, file) do
    %File{operations: operations, schemas: schemas} = file

    struct_schema_count = Enum.count(schemas, fn schema -> schema.output_format == :struct end)
    operation_use = config(state)[:operation_use]
    schema_use = config(state)[:schema_use]

    Util.clean_list([
      if length(operations) > 0 && operation_use do
        quote do: use(unquote(operation_use))
      end,
      if struct_schema_count > 0 && schema_use do
        quote do: use(unquote(schema_use))
      end
    ])
  end

  @doc """
  Construct the `@default_client` module attribute for modules with operations

  Default implementation of `c:OpenAPI.Renderer.render_default_client/2`.

  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.

  ## Configuration

  Use `output.default_client` to choose which module is set. A value of `false` or `nil` will
  cause the module attribute not to be set at all, which may cause compilation errors if using
  the default implementation of the operation function renderer.

      config :oapi_generator, default: [
        output: [
          default_client: MyLib.MyClient
        ]
      ]

  This will result in a statement `@default_client MyLib.MyClient` to be added to any module that
  contains operations. Note that the `output.base_module` configuration is not used in this case.

  If the configuration is unset, then a default module of `[base module].Client` will be used,
  based on the `output.base_module` configuration.
  """
  @spec render_default_client(State.t(), File.t()) :: Macro.t()
  def render_default_client(state, file)
  def render_default_client(_state, %File{operations: []}), do: []

  def render_default_client(state, _file) do
    config = config(state)
    fallback = Module.concat([Keyword.get(config, :base_module), Client])

    case Keyword.get(config, :default_client, :__undefined__) do
      disabled when disabled in [false, nil] ->
        []

      :__undefined__ ->
        quote do
          @default_client unquote(fallback)
        end
        |> Util.put_newlines()

      module ->
        quote do
          @default_client unquote(module)
        end
        |> Util.put_newlines()
    end
  end

  #
  # Helpers
  #

  @spec config(OpenAPI.Renderer.State.t()) :: Keyword.t()
  defp config(state) do
    %OpenAPI.Renderer.State{profile: profile} = state

    Application.get_env(:oapi_generator, profile, [])
    |> Keyword.get(:output, [])
  end

  @spec plural(list, String.t()) :: String.t()
  defp plural(list, word)
  defp plural(list, word) when length(list) == 1, do: word
  defp plural(_list, word), do: word <> "s"
end