lib/mix/tasks/absinthe.schema.json.ex

defmodule Mix.Tasks.Absinthe.Schema.Json do
  require Logger
  use Mix.Task
  import Mix.Generator

  @shortdoc "Generate a schema.json file for an Absinthe schema"

  @default_filename "./schema.json"

  @moduledoc """
  Generate a `schema.json` file

  ## Usage

      mix absinthe.schema.json [OPTIONS] [FILENAME]

  The JSON codec to be used needs to be included in your `mix.exs` dependencies. If using the default codec,
  see the Jason [installation instructions](https://hexdocs.pm/jason).

  ## Options

  * `--schema` - The name of the `Absinthe.Schema` module defining the schema to be generated.
     Default: As [configured](https://hexdocs.pm/mix/Mix.Config.html) for `:absinthe` `:schema`
  * `--json-codec` - Codec to use to generate the JSON file (see [Custom Codecs](#module-custom-codecs)).
     Default: [`Jason`](https://hexdocs.pm/jason/)
  * `--pretty` - Whether to pretty-print.
     Default: `false`

  ## Examples

  Write to default path `#{@default_filename}` using the `:schema` configured for the `:absinthe` application:

      mix absinthe.schema.json

  Write to default path `#{@default_filename}` using the `MySchema` schema:

      mix absinthe.schema.json --schema MySchema

  Write to path `/path/to/schema.json` using the `MySchema` schema, with pretty-printing:

      mix absinthe.schema.json --schema MySchema --pretty /path/to/schema.json

  Write to default path `#{@default_filename}` using the `MySchema` schema and a custom JSON codec, `MyCodec`:

      mix absinthe.schema.json --schema MySchema --json-codec MyCodec


  ## Custom Codecs

  Any module that provides `encode!/2` can be used as a custom codec:

      encode!(value, options)

  * `value` will be provided as a Map containing the generated schema.
  * `options` will be a keyword list with a `:pretty` boolean, indicating whether the user requested pretty-printing.

  The function should return a string to be written to the output file.
  """

  defmodule Options do
    @moduledoc false

    defstruct filename: nil, schema: nil, json_codec: nil, pretty: false

    @type t() :: %__MODULE__{
            filename: String.t(),
            schema: module(),
            json_codec: module(),
            pretty: boolean()
          }
  end

  @doc "Callback implementation for `Mix.Task.run/1`, which receives a list of command-line args."
  @spec run(argv :: [binary()]) :: any()
  def run(argv) do
    Application.ensure_all_started(:absinthe)

    Mix.Task.run("loadpaths", argv)
    Mix.Task.run("compile", argv)

    opts = parse_options(argv)

    # Start Absinthe.Schema if using persistent term provider
    if opts.schema.__absinthe_schema_provider__() == Absinthe.Schema.PersistentTerm do
      Supervisor.start_link([{Absinthe.Schema, opts.schema}], strategy: :one_for_one)
    end

    case generate_schema(opts) do
      {:ok, content} -> write_schema(content, opts.filename)
      {:error, error} -> raise error
    end
  end

  @doc false
  @spec generate_schema(Options.t()) :: {:error, binary()} | {:ok, String.t()}
  def generate_schema(%Options{
        pretty: pretty,
        schema: schema,
        json_codec: json_codec
      }) do
    with {:ok, result} <- Absinthe.Schema.introspect(schema) do
      content = json_codec.encode!(result, pretty: pretty)
      {:ok, content}
    else
      {:error, reason} -> {:error, reason}
      error -> {:error, error}
    end
  end

  @doc false
  @spec parse_options([String.t()]) :: Options.t()
  def parse_options(argv) do
    parse_options = [strict: [schema: :string, json_codec: :string, pretty: :boolean]]
    {opts, args, _} = OptionParser.parse(argv, parse_options)

    %Options{
      filename: args |> List.first() || @default_filename,
      schema: find_schema(opts),
      json_codec: json_codec_as_atom(opts),
      pretty: Keyword.get(opts, :pretty, false)
    }
  end

  defp json_codec_as_atom(opts) do
    opts
    |> Keyword.fetch(:json_codec)
    |> case do
      {:ok, codec} -> Module.concat([codec])
      _ -> Jason
    end
  end

  defp find_schema(opts) do
    case Keyword.get(opts, :schema, Application.get_env(:absinthe, :schema)) do
      nil ->
        raise "No --schema given or :schema configured for the :absinthe application"

      value ->
        [value] |> Module.safe_concat()
    end
  end

  defp write_schema(content, filename) do
    create_directory(Path.dirname(filename))
    create_file(filename, content, force: true)
  end
end