lib/jungle_spec.ex

defmodule JungleSpec do
  @moduledoc """
  A module providing simplified and less verbose way of definining OpenApiSpex types.

  JungleSpec provides two main entry points: `open_api_object` to define an object, and `open_api_type` to define
  a non-object type. Both generate an OpenApiSpec schema (`OpenApiSpex.Schema`) and an internal Elixir type.

  Only a single `open_api_object` or `open_api_type` clause is allowed per module, as the underlying Elixir type
  is defined as the `t()`-type within that module.

  The generated OpenApiSpex schema can be obtained by calling `schema/1` function on the module.

  The OpenAPI specification requires that each schemas has a unique name, called title. This is provided as the
  first argument for both `open_api_object` and `open_api_type` and must be a string.

  # Non-object Schemas
  `open_api_type` is used to define non-object schemas. That could be enumerable strings, regexp-limited strings,
  union types, and similar.

  Example:

      defmodule EnumExample do
        use JungleSpec

        open_api_type "NonObjectExample", :string, enum: ["ValueA", "ValueB"]
      end

  The above example will create the following OpenApiSpex schema struct:

      %OpenApiSpex.Schema%{
        type: :string,
        enum: ["ValueA", "ValueB"],
        title: "NonObjectExample",
        nullable: false
      }

  Elixir-wise, the `t` type is defined as

      @type t() :: String.t()


  See `open_api_type/3` for more details in supported types and options.

  # Object Schemas
  `open_api_object/3` is used to specify object-type schemas. By default, object schemas are mapped to Elixir structs,
  but can be mapped to maps instead (see the "Elixir Struct or Map"-section for the details).

  `open_api_object` is used to set the title of the object-schema, and object-level options.
  The properties of the object are defined using the `property/3` macro. Here two parameters are required: The
  name and type of the property.

  Example:

      defmodule Person do
        use JungleSpec

        open_api_object "Person" do
          property :name, :string
          property :age, :integer
        end
      end

  This will generate the following OpenApiSpex Schema struct:

      %OpenApiSpex.Schema%{
        type: :object,
        title: "Person",
        required: [:name, :age],
        nullable: false,
        properties: %{
          name: %OpenApiSpex.Schema%{type: :string, nullable: false},
          age: %OpenApiSpex.Schema%{type: :integer, nullable: false}
        },
        "x-struct": Person
      }

  And the following Elixir type (struct):

      @type t() :: %Person{
                age: integer(),
                name: String.t()
              }

  ## Required and Nullable Properties
  By default, all properties are "required". That means that the generated OpenApiSpec schema will have all
  properties in the list of `required` properties.

  It is possible to set `required: false` for a specific property. However, then a `default` value MUST be provided,
  or a compile-time error is emitted:

      defmodule Person do
        use JungleSpec

        open_api_object "Person" do
          property :name, :string
          property :age, :integer, required: false, default: 0
        end
      end

  Please note, that even when `required: false` for a field, the Elixir struct still needs the value provided!

  It is also possible to set `required: false` on the entire object, and only setting `required: true` on some fields:

      defmodule Person do
        use JungleSpec

        open_api_object "Person", required: false do
          property :name, :string, required: true
          property :age, :integer, default: 0
        end
      end

  A default value must still be provided for `:age` in order for this to compile.

  All properties have a default setting of `nullable: false`, i.e. they cannot be `nil`/`null`.
  By setting `nullable: true` on a property, `nil`/`null` becomes a valid value. This will also change the Elixir-struct,
  such that the property is no longer required (but will default to `nil` if not given) -- even when a default value other
  than `nil` is provided.

  ## Elixir Struct or Map
  Elixir structs have the property, that all declared fields are always present. If it is necessary to define an
  object, where not all properties are present all the time, we cannot map that into an Elixir struct. Instead we
  can map it to an Elixir map, by providing the `struct?: false` option to `open_api_object`:

      defmodule Person do
        use JungleSpec

        open_api_object "Person", struct?: false do
          property :name, :string
          property :age, :integer, required: false
        end
      end

  Note, that it is no longer necessary to provide a default value for `:age`, as the field is allowed not to be
  present. The Elixir type for this schema is:

      @type t() :: %{
                age: integer(),
                name: String.t()
              }

  Two important things to note here: First, it is a map, an not a struct. Second, it wrongly, says that `:age` is required.
  The typespec should have had an `optional(age) => integer()`, but this has not yet been implemented.
  In practice, this is only an issue if the type is used, and Dialyzer catches the bug. The main important point of doing
  this, is that the generated OpenApiSpex schema struct instructs OpenApiSpex to generate a map and not a struct.
  Not optimal, but it works :-)

  ## Schema Extension (Inheritance)
  Sometimes, it can be helpful to create a schema that is an extension of another schema.

  The following schema extends the `Person` schema used as a previous example:

      defmodule Employee do
        use JungleSpec

        open_api_object "Employee", extends: Person do
          property :level, :string, enum: ["L1", "L2", "L3"]
          property :experience, [:number, :string]
        end
      end

    This will simply copy over all properties from `Person` and include them along with the the new ones provided.
    The OpenApiSpex schema structure would look like:

      %OpenApiSpex.Schema%{
        type: :object,
        title: "Employee",
        required: [:name, :age, :level, :experience],
        nullable: false,
        properties: %{
          name: %OpenApiSpex.Schema%{type: :string, nullable: false},
          level: %OpenApiSpex.Schema%{
            type: :string,
            enum: ["L1", "L2", "L3"],
            nullable: false
          },
          experience: %OpenApiSpex.Schema%{
            oneOf: [
              %OpenApiSpex.Schema%{type: :number, nullable: false},
              %OpenApiSpex.Schema%{type: :string, nullable: false}
            ]
          },
          age: %OpenApiSpex.Schema%{type: :integer, nullable: false}
        },
        "x-struct": Employee
      }

    While the Elixir type will be:

      @type t() :: %Employee{
          age: integer(),
          experience: number() | String.t(),
          level: String.t(),
          name: String.t()
        }
  """

  alias OpenApiSpex.Schema

  defmacro __using__(_) do
    quote do
      import JungleSpec,
        only: [
          additional_properties: 2,
          open_api_object: 1,
          open_api_object: 2,
          open_api_object: 3,
          open_api_type: 3,
          property: 3
        ]
    end
  end

  @doc """
  Defines OpenApiSpex schema with `:object` type and appropriate typespec.

  It has one required argument:

    * `title` - a binary being a title of the schema

  It is followed by an optional keyword list of options and an optional `do ... end` block
  containing object's properties.

  Possible options

    * `:description` - a binary describing the whole object

    * `:example` - a map being an example of the object defined by the schema

    * `:extends` - a module of the schema that this schema extends. All properties of the extended
      schema will be added to the current schema, preserving the information if the properties are
      required. It does not propagate nullability of the extended schema. Ensure that
      `open_api_object/3` macro was used to define the extended module

    * `:inline` - a boolean value specifying the default `inline` option for all properties. By
      default it is `false`

    * `:nullable` - a boolean value specifying whether the object can be nullable. `false` by
      default

    * `:required` - a boolean value specifying the default `required` option for all properties. By
      default it is `true`

    * `:struct?` - a boolean value telling OpenApiSpex if it should create a struct out of the
      object. It will also add the struct to the typespec if set to `true`. By default it is `true`
  """
  defmacro open_api_object(title, opts, do: block) do
    quote do
      Module.register_attribute(__MODULE__, :properties, accumulate: true)
      Module.register_attribute(__MODULE__, :additional_properties, accumulate: false)
      Module.register_attribute(__MODULE__, :references_to_modules, accumulate: true)

      import JungleSpec
      import JungleSpec.TypespecGenerator, only: [define_object_type: 4]

      require OpenApiSpex

      unquote(block)

      object_schema = prepare_object_schema(__MODULE__, unquote(title), @properties, @additional_properties, unquote(opts))

      maybe_define_struct(object_schema, unquote(opts))
      define_object_type(__MODULE__, object_schema, @references_to_modules, unquote(opts))

      OpenApiSpex.schema(object_schema, struct?: false, derive?: false)
    end
  end

  defmacro open_api_object(title, do: block) do
    quote do
      open_api_object(unquote(title), [], do: unquote(block))
    end
  end

  defmacro open_api_object(title, opts) do
    quote do
      open_api_object(unquote(title), unquote(opts), do: nil)
    end
  end

  defmacro open_api_object(title) do
    quote do
      open_api_object(unquote(title), [], do: nil)
    end
  end

  @doc """
  Defines OpenApiSpex schema with type different from `:object` and appropriate typespec.

  It has two required arguments:

    * `title` - a binary being a title of the schema

    * `type` - an atom proving the type of the schema.

  It is followed by an optional keyword list of options.

  Allowed types:

    * `:integer` - maps to the type of the same name.

    * `:number` - either integer or float. Maps to the type of the same name.

    * `:string` - a binary. Maps to the type of the same name.

    * `:boolean` - maps to the type of the same name.

    * `{:array, type}` - a list of elements having provided type. `type` have to be one of the
      allowed types.

    * `{:map, type}` - a nested object with additional properties having provided `type`, which
      have to be one of the allowed types.

    * `[type_1, type_2, ...]` - a union of the provided types. The types have to be allowed.

    * `module` - a module name that has it's own schema.

  Supported options are:

    * `:default` - default value for the property. It has to match type of the property.

    * `:description` - a binary describing the property.

    * `:enum` - a list of possible values which have to be binaries. Only valid for `:string` type.

    * `:example` - an example of the property. It has to match its type.

    * `:format` - an atom describing property's format.

    * `:inline` - a boolean value that is used if the property's type is another module. Then,
      if `inline: true`, the module's schema is just inlined. Otherwise, only the reference is
      used. By default, the value is propagated from the object.

    * `:nullable` - a boolean value specifying if the property can be `nil`. `false` by default.

    * `:pattern` - a regular expression describing possible format of the property. Only valid for `:string` type.
  """
  defmacro open_api_type(title, type, opts \\ []) do
    quote do
      Module.register_attribute(__MODULE__, :references_to_modules, accumulate: true)

      import JungleSpec
      import JungleSpec.TypespecGenerator, only: [define_type: 2]

      require OpenApiSpex

      type_schema = prepare_type_schema(__MODULE__, unquote(title), unquote(type), unquote(opts))
      define_type(type_schema, @references_to_modules)

      OpenApiSpex.schema(type_schema, struct?: false)
    end
  end

  @doc """
  Defines one property for the open_api_object.

  It has two required arguments:

    * `name` - an atom being a name of the property

    * `type` - an atom or a tuple proving the type of the schema

  Allowed types:

    * `:integer` - maps to the type of the same name

    * `:number` - either integer or float. Maps to the type of the same name

    * `:string` - a binary. Maps to the type of the same name

    * `:boolean` - maps to the type of the same name

    * `{:array, type}` - a list of elements having provided type. `type` have to be one of the
      allowed types

    * `{:map, type}` - a nested object with additional properties having provided `type`, which
      have to be one of the allowed types

    * `[type_1, type_2, ...]` - a union of the provided types. The types have to be allowed

    * `module` - a module name that has it's own schema

  Property also has an optional keyword list with the following possible options:

    * `:default` - default value for the property. It has to match type of the property

    * `:description` - a binary describing the property

    * `:enum` - a list of possible values which have to be binaries. Also, property has to have
      `:string` type

    * `:example` - an example of the property. It has to match its type

    * `:format` - an atom describing property's format

    * `:inline` - a boolean value that is used if the property's type is another module. Then,
      if `inline: true`, the module's schema is just inlined. Otherwise, only the reference is
      used. By default, the value is propagated from the object

    * `:nullable` - a boolean value specifying if the property can be `nil`. `false` by default

    * `:pattern` - a regular expression describing possible format of the property

    * `:required` - a boolean value specifying if the property should be added to the list of
      object's required properties. By default, it is propagated from the object
  """
  defmacro property(name, type, opts \\ []) do
    quote do
      JungleSpec.define_property(__MODULE__, unquote(name), unquote(type), unquote(opts))
    end
  end

  @doc """
  Macro for defining object's `additionalProperties`. It has one required argument:

    * `type` - type have to be one of the allowed types defined in the `property` macro

  It has also one option:

    * `:nullable` - a boolean value specifying if the additional properties can be nullable.
    `false` by default
  """
  defmacro additional_properties(type, opts \\ []) do
    quote do
      Module.put_attribute(__MODULE__, :additional_properties, {unquote(type), unquote(opts)})
    end
  end

  def prepare_object_schema(module, title, properties, additional_properties, opts) do
    properties = propagate_general_opts(properties, opts)

    validate_object_opts!(properties, additional_properties, opts)

    nullable = Keyword.get(opts, :nullable, false)

    properties_schemas =
      Map.new(properties, fn {name, type, property_opts} ->
        {name, prepare_property_schema(module, title, name, type, property_opts)}
      end)

    required = required_properties_names(properties)

    schema_map =
      %{
        nullable: nullable,
        properties: properties_schemas,
        required: required,
        title: title,
        type: :object
      }
      |> maybe_add_additional_properties(module, title, additional_properties)
      |> maybe_add_opts([:description, :example], opts)
      |> maybe_add_xstruct(module, opts)
      |> maybe_extend_object(opts)

    struct(Schema, schema_map)
  end

  def prepare_type_schema(module, title, type, opts) do
    %Schema{prepare_property_schema(module, title, title, type, opts) | title: title}
  end

  defp propagate_general_opts(properties, opts) do
    general_opts = [
      inline: Keyword.get(opts, :inline, false),
      required: Keyword.get(opts, :required, true)
    ]

    Enum.map(properties, fn {name, type, property_opts} ->
      {name, type, Keyword.merge(general_opts, property_opts)}
    end)
  end

  defp validate_object_opts!(properties, additional_properties, object_opts) do
    struct? = Keyword.get(object_opts, :struct?, true)

    if struct? and not is_nil(additional_properties) do
      raise ArgumentError, "Struct cannot be defined with additional_properties"
    end

    if struct? and
         Enum.any?(properties, fn {_name, _type, property_opts} ->
           required = Keyword.get(property_opts, :required)
           nullable = Keyword.get(property_opts, :nullable, false)
           has_default = not (property_opts |> Keyword.get(:default) |> is_nil())

           not required and not nullable and not has_default
         end) do
      raise ArgumentError, "Struct cannot have not required properties which are not nullable and do not have default values"
    end
  end

  defp prepare_property_schema(module, title, name, {:array, type}, opts) do
    validate_opts!(name, {:array, type}, opts)

    items_schema = prepare_property_schema(module, title, name, type, clear_opts_for_nested_types(opts))

    nullable = Keyword.get(opts, :nullable, false)

    schema_map =
      maybe_add_opts(
        %{type: :array, items: items_schema, nullable: nullable},
        [:description, :default],
        opts
      )

    struct(Schema, schema_map)
  end

  defp prepare_property_schema(module, title, name, {:map, type}, opts) do
    validate_opts!(name, {:map, type}, opts)

    nested_properties_schema = prepare_property_schema(module, title, name, type, clear_opts_for_nested_types(opts))

    nullable = Keyword.get(opts, :nullable, false)

    schema_map =
      maybe_add_opts(
        %{type: :object, properties: %{}, additionalProperties: nested_properties_schema, nullable: nullable},
        [:description, :default],
        opts
      )

    struct(Schema, schema_map)
  end

  defp prepare_property_schema(module, title, name, union_types, opts) when is_list(union_types) do
    validate_opts!(name, union_types, opts)

    items_schema =
      union_types
      |> Enum.map(&prepare_property_schema(module, title, name, &1, clear_opts_for_nested_types(opts)))
      |> Enum.uniq()

    schema_map =
      maybe_add_opts(
        %{oneOf: items_schema},
        [:description, :default],
        opts
      )

    struct(Schema, schema_map)
  end

  @primitive_types [:integer, :number, :string, :boolean]
  defp prepare_property_schema(_module, _title, name, type, opts) when type in @primitive_types do
    validate_opts!(name, type, opts)

    nullable = Keyword.get(opts, :nullable, false)

    schema_map =
      maybe_add_opts(
        %{type: type, nullable: nullable},
        [:description, :default, :format, :pattern, :example, :enum],
        opts
      )

    struct(Schema, schema_map)
  end

  defp prepare_property_schema(module, title, name, property_module, opts) do
    validate_opts!(name, property_module, opts)

    Code.ensure_compiled!(property_module)

    module_schema =
      if Keyword.get(opts, :inline, false) do
        property_module
      else
        reference =
          if module == property_module do
            "#/components/schemas/" <> title
          else
            "#/components/schemas/" <> property_module.schema().title
          end

        Module.put_attribute(module, :references_to_modules, {reference, property_module})
        %OpenApiSpex.Reference{"$ref": reference}
      end

    nullable = Keyword.get(opts, :nullable, false)

    cond do
      Keyword.has_key?(opts, :default) ->
        default = Keyword.get(opts, :default)
        %Schema{oneOf: [module_schema], default: default, nullable: nullable}

      nullable ->
        %Schema{oneOf: [module_schema], nullable: nullable}

      true ->
        module_schema
    end
  end

  defp validate_opts!(name, type, opts) do
    if Keyword.has_key?(opts, :enum) do
      if type != :string do
        raise ArgumentError,
              "#{name} has an enum option, but it can be provided only for string type"
      end

      if opts |> Keyword.get(:enum) |> Enum.any?(fn item -> not is_binary(item) end) do
        raise ArgumentError,
              "#{name} has values of invalid types in the enum option. They should be binaries"
      end
    end

    if Keyword.has_key?(opts, :default) do
      if not (opts |> Keyword.get(:default) |> has_valid_type?(type)) do
        raise ArgumentError, "default value of #{name} does not match its type"
      end
    end
  end

  defp has_valid_type?(default, types_union) when is_list(types_union) do
    Enum.any?(types_union, fn expected_type -> has_valid_type?(default, expected_type) end)
  end

  defp has_valid_type?(default, expected_type) when is_integer(default) do
    expected_type in [:integer, :number]
  end

  defp has_valid_type?(default, expected_type) when is_number(default) do
    expected_type == :number
  end

  defp has_valid_type?(default, expected_type) when is_binary(default) do
    expected_type == :string
  end

  defp has_valid_type?(default, expected_type) when is_boolean(default) do
    expected_type == :boolean
  end

  defp has_valid_type?(default, {:array, expected_type}) when is_list(default) do
    Enum.all?(default, fn default_item -> has_valid_type?(default_item, expected_type) end)
  end

  defp has_valid_type?(default, {:map, expected_type}) when is_map(default) do
    Enum.all?(default, fn {key, value} -> is_binary(key) and has_valid_type?(value, expected_type) end)
  end

  defp has_valid_type?(_default, _expected_type), do: false

  defp clear_opts_for_nested_types(opts) do
    Enum.reduce([:nullable, :default, :description, :enum], opts, fn key, opts ->
      Keyword.delete(opts, key)
    end)
  end

  defp required_properties_names(properties) do
    properties
    |> Enum.filter(fn {_name, _type, opts} -> Keyword.get(opts, :required, true) end)
    |> Enum.map(fn {name, _type, _opts} -> name end)
    |> Enum.reverse()
  end

  defp maybe_add_additional_properties(schema_map, module, title, {type, opts}) do
    additional_properties_schema = prepare_property_schema(module, title, :additional_properties, type, opts)

    Map.put(schema_map, :additionalProperties, additional_properties_schema)
  end

  defp maybe_add_additional_properties(schema_map, _module, _title, nil) do
    schema_map
  end

  defp maybe_add_opts(schema_map, keys, opts) do
    Enum.reduce(keys, schema_map, fn key, map ->
      if Keyword.has_key?(opts, key) do
        Map.put(map, key, Keyword.get(opts, key))
      else
        map
      end
    end)
  end

  defp maybe_add_xstruct(schema, module, opts) do
    if Keyword.get(opts, :struct?, true) do
      Map.put(schema, :"x-struct", module)
    else
      schema
    end
  end

  defp maybe_extend_object(schema_map, opts) do
    if Keyword.has_key?(opts, :extends) do
      module = Keyword.get(opts, :extends)
      Code.ensure_compiled!(module)

      properties_to_add = module.schema().properties
      required_to_add = module.schema().required

      schema_map
      |> Map.update(:properties, properties_to_add, fn properties -> Map.merge(properties_to_add, properties) end)
      |> Map.update(:required, required_to_add, fn required -> required_to_add ++ required end)
    else
      schema_map
    end
  end

  defmacro maybe_define_struct(schema, opts) do
    quote do
      struct? = Keyword.get(unquote(opts), :struct?, true)

      if struct? do
        @derive Enum.filter([Poison.Encoder, Jason.Encoder], &Code.ensure_loaded?/1)

        @enforce_keys enforced_properties(unquote(schema))
        defstruct struct_definition(unquote(schema))
      end

      unquote(schema)
    end
  end

  def enforced_properties(schema) do
    schema.properties
    |> Enum.filter(fn
      {_name, %Schema{nullable: nil}} -> false
      {_name, %Schema{nullable: nullable}} -> not nullable
      {_name, %Schema{default: _default}} -> false
      {_name, _module} -> true
    end)
    |> Enum.map(fn {name, _property_schema} -> name end)
  end

  def struct_definition(schema) do
    Enum.map(schema.properties, fn {name, property_schema} -> {name, default_value(property_schema)} end)
  end

  defp default_value(%Schema{default: default}), do: default
  defp default_value(_), do: nil

  def define_property(module, name, type, opts) do
    if module |> Module.get_attribute(:properties) |> Keyword.has_key?(name) do
      raise ArgumentError, "the property #{inspect(name)} is already set"
    end

    Module.put_attribute(module, :properties, {name, type, opts})
  end
end