lib/open_api_spex/schema.ex

defmodule OpenApiSpex.Schema do
  @moduledoc """
  Defines the `OpenApiSpex.Schema.t` type and operations for casting and validating against a schema.

  The `OpenApiSpex.schema` macro can be used to declare schemas with an associated struct and JSON Encoder.

  ## Examples

      defmodule MyApp.Schemas do
        defmodule EmailString do
          @behaviour OpenApiSpex.Schema
          def schema do
            %OpenApiSpex.Schema {
              title: "EmailString",
              type: :string,
              format: :email
            }
          end
        end

        defmodule Person do
          require OpenApiSpex
          alias OpenApiSpex.{Reference, Schema}

          OpenApiSpex.schema(%{
            type: :object,
            required: [:name],
            properties: %{
              name: %Schema{type: :string},
              address: %Reference{"$ref": "#/components/schemas/Address"},
              age: %Schema{type: :integer, format: :int32, minimum: 0}
            }
          })
        end

        defmodule StringDictionary do
          @behaviour OpenApiSpex.Schema

          def schema() do
            %OpenApiSpex.Schema{
              type: :object,
              additionalProperties: %OpenApiSpex.Schema{
                type: :string
              }
            }
          end
        end

        defmodule PetCommon do
          require OpenApiSpex
          alias OpenApiSpex.{Schema, Discriminator}
          OpenApiSpex.schema(%{
            title: "PetCommon",
            description: "Properties common to all Pets",
            type: :object,
            properties: %{
              name: %Schema{type: :string},
              petType: %Schema{type: :string}
            },
            required: [:name, :petType]
          })
        end

        defmodule Cat do
          require OpenApiSpex
          alias OpenApiSpex.Schema
          OpenApiSpex.schema(%{
            title: "Cat",
            type: :object,
            description: "A representation of a cat. Note that `Cat` will be used as the discriminator value.",
            allOf: [
              PetCommon,
              %Schema{
                type: :object,
                properties: %{
                  huntingSkill: %Schema{
                    type: :string,
                    description: "The measured skill for hunting",
                    default: "lazy",
                    enum: ["clueless", "lazy", "adventurous", "aggressive"]
                  }
                },
                required: [:huntingSkill]
              }
            ]
          })
        end

        defmodule Dog do
          require OpenApiSpex
          alias OpenApiSpex.Schema
          OpenApiSpex.schema(%{
            type: :object,
            title: "Dog",
            description: "A representation of a dog. Note that `Dog` will be used as the discriminator value.",
            allOf: [
              PetCommon,
              %Schema {
                type: :object,
                properties: %{
                  packSize: %Schema{
                    type: :integer,
                    format: :int32,
                    description: "the size of the pack the dog is from",
                    default: 0,
                    minimum: 0
                  }
                },
                required: [
                  :packSize
                ]
              }
            ]
          })
        end

        defmodule Pet do
          require OpenApiSpex
          alias OpenApiSpex.Discriminator
          OpenApiSpex.schema(%{
            title: "Pet",
            type: :object,
            discriminator: %Discriminator{
              propertyName: "petType"
            },
            oneOf: [
              Cat,
              Dog
            ]
          })
        end
      end
  """

  alias OpenApiSpex.{
    DeprecatedCast,
    Discriminator,
    ExternalDocumentation,
    Reference,
    Schema,
    Xml
  }

  @doc """
  A module implementing the `OpenApiSpex.Schema` behaviour should export a `schema/0` function
  that produces an `OpenApiSpex.Schema` struct.
  """
  @callback schema() :: t

  defstruct [
    :title,
    :multipleOf,
    :maximum,
    :exclusiveMaximum,
    :minimum,
    :exclusiveMinimum,
    :maxLength,
    :minLength,
    :pattern,
    :maxItems,
    :minItems,
    :uniqueItems,
    :maxProperties,
    :minProperties,
    :required,
    :enum,
    :type,
    :allOf,
    :oneOf,
    :anyOf,
    :not,
    :items,
    :properties,
    :additionalProperties,
    :description,
    :format,
    :default,
    :nullable,
    :discriminator,
    :readOnly,
    :writeOnly,
    :xml,
    :externalDocs,
    :example,
    :deprecated,
    :"x-struct",
    :"x-validate",
    :extensions
  ]

  @typedoc """
  [Schema Object](https://swagger.io/specification/#schemaObject)

  The Schema Object allows the definition of input and output data types.
  These types can be objects, but also primitives and arrays.
  This object is an extended subset of the JSON Schema Specification Wright Draft 00.

  ## Example
      alias OpenApiSpex.Schema

      %Schema{
        title: "User",
        type: :object,
        properties: %{
          id: %Schema{type: :integer, minimum: 1},
          name: %Schema{type: :string, pattern: "[a-zA-Z][a-zA-Z0-9_]+"},
          email: %Schema{type: :string, format: :email},
          last_login: %Schema{type: :string, format: :"date-time"}
        },
        required: [:name, :email],
        example: %{
          "name" => "joe",
          "email" => "joe@gmail.com"
        }
      }
  """
  @type t :: %__MODULE__{
          title: String.t() | nil,
          multipleOf: number | nil,
          maximum: number | nil,
          exclusiveMaximum: boolean | nil,
          minimum: number | nil,
          exclusiveMinimum: boolean | nil,
          maxLength: integer | nil,
          minLength: integer | nil,
          pattern: String.t() | Regex.t() | nil,
          maxItems: integer | nil,
          minItems: integer | nil,
          uniqueItems: boolean | nil,
          maxProperties: integer | nil,
          minProperties: integer | nil,
          required: [atom] | nil,
          enum: [any] | nil,
          type: data_type | nil,
          allOf: [Schema.t() | Reference.t() | module] | nil,
          oneOf: [Schema.t() | Reference.t() | module] | nil,
          anyOf: [Schema.t() | Reference.t() | module] | nil,
          not: Schema.t() | Reference.t() | module | nil,
          items: Schema.t() | Reference.t() | module | nil,
          properties: %{atom => Schema.t() | Reference.t() | module} | nil,
          additionalProperties: boolean | Schema.t() | Reference.t() | module | nil,
          description: String.t() | nil,
          format: String.t() | atom | nil,
          default: any,
          nullable: boolean | nil,
          discriminator: Discriminator.t() | nil,
          readOnly: boolean | nil,
          writeOnly: boolean | nil,
          xml: Xml.t() | nil,
          externalDocs: ExternalDocumentation.t() | nil,
          example: any,
          deprecated: boolean | nil,
          "x-struct": module | nil,
          "x-validate": module | nil,
          extensions: %{String.t() => any()} | nil
        }

  @typedoc """
  The basic data types supported by openapi.

  [Reference](https://swagger.io/docs/specification/data-models/data-types/)
  """
  @type data_type :: :string | :number | :integer | :boolean | :array | :object

  @typedoc """
  Global schemas lookup by name.
  """
  @type schemas :: %{String.t() => t()}

  @doc """
  Cast a simple value to the elixir type defined by a schema.

  By default, object types are cast to maps, however if the "x-struct" attribute is set in the schema,
  the result will be constructed as an instance of the given struct type.

  ## Examples

      iex> OpenApiSpex.Schema.cast(%Schema{type: :integer}, "123", %{})
      {:ok, 123}

      iex> {:ok, dt = %DateTime{}} = OpenApiSpex.Schema.cast(%Schema{type: :string, format: :"date-time"}, "2018-04-02T13:44:55Z", %{})
      ...> dt |> DateTime.to_iso8601()
      "2018-04-02T13:44:55Z"

  ## Casting Polymorphic Schemas

  Schemas using `discriminator`, `allOf`, `oneOf`, `anyOf` are cast using the following rules:

    - If a `discriminator` is present, cast the properties defined in the base schema, then
      cast the result using the schema identified by the discriminator. To avoid infinite recursion,
      the discriminator is only dereferenced if the discriminator property has not already been cast.

    - Cast the properties using each schema listing in `allOf`. When a property is defined in
      multiple `allOf` schemas, it will be cast using the first schema listed containing the property.

    - Cast the value using each schema listed in `oneOf`, stopping as soon as a successful cast is made.

    - Cast the value using each schema listed in `anyOf`, stopping as soon as a successful cast is made.
  """
  defdelegate cast(schema, value, schemas), to: DeprecatedCast

  @doc ~S"""
  Validate a value against a Schema.

  This expects that the value has already been `cast` to the appropriate data type.

  ## Examples

      iex> OpenApiSpex.Schema.validate(%OpenApiSpex.Schema{type: :integer, minimum: 5}, 3, %{})
      {:error, "#: 3 is smaller than minimum 5"}

      iex> OpenApiSpex.Schema.validate(%OpenApiSpex.Schema{type: :string, pattern: "(.*)@(.*)"}, "joe@gmail.com", %{})
      :ok

      iex> OpenApiSpex.Schema.validate(%OpenApiSpex.Schema{type: :string, pattern: "(.*)@(.*)"}, "joegmail.com", %{})
      {:error, "#: Value \"joegmail.com\" does not match pattern: (.*)@(.*)"}
  """
  @spec validate(Schema.t() | Reference.t(), any, %{String.t() => Schema.t() | Reference.t()}) ::
          :ok | {:error, String.t()}
  defdelegate validate(schema, value, schemas), to: DeprecatedCast
  defdelegate validate(schema, value, path, schemas), to: DeprecatedCast

  @doc """
  Get the names of all properties defined for a schema.

  Includes all properties directly defined in the schema, and all schemas
  included in the `allOf` list.
  """
  def properties(schema = %Schema{type: :object, properties: properties = %{}}) do
    properties
    |> Enum.map(fn {name, property} -> {name, default(property)} end)
    |> Enum.concat(properties(%{schema | properties: nil}))
    |> Enum.uniq_by(fn {name, _property} -> name end)
  end

  def properties(%Schema{allOf: schemas}) when is_list(schemas) do
    Enum.flat_map(schemas, &properties/1) |> Enum.uniq()
  end

  def properties(schema_module) when is_atom(schema_module) do
    properties(schema_module.schema())
  end

  def properties(_), do: []

  @doc """
  Generate example value from a `%Schema{}` struct.

  This is useful as a simple way to generate values for tests.

  Example:

      test "create user", %{conn: conn} do
        user_request_schema = MyAppWeb.Schemas.UserRequest.schema()
        req_body = OpenApiSpex.Schema.example(user_request_schema)

        resp_body =
          conn
          |> post("/users", req_body)
          |> json_response(201)

        assert ...
      end
  """
  def example(%Schema{example: example} = schema) when not is_nil(example) do
    schema.example
  end

  def example(%Schema{enum: [example | _]}) do
    example
  end

  def example(%Schema{oneOf: [schema | _]}) do
    example(schema)
  end

  def example(%Schema{allOf: schemas}) when is_list(schemas) do
    example_for(schemas, :allOf)
  end

  def example(%Schema{anyOf: schemas}) when is_list(schemas) do
    example_for(schemas, :anyOf)
  end

  def example(%Schema{type: :object} = schema) do
    Map.new(properties(schema), fn {prop_name, _} ->
      property = schema.properties[prop_name]
      example_value = example(property)
      {prop_name, example_value}
    end)
  end

  def example(%Schema{type: :array} = schema) do
    item_example = example(schema.items)
    [item_example]
  end

  def example(%Schema{type: :string, format: :date}), do: "2020-04-20"
  def example(%Schema{type: :string, format: :"date-time"}), do: "2020-04-20T16:20:00Z"
  def example(%Schema{type: :string, format: :uuid}), do: "02ef9c5f-29e6-48fc-9ec3-7ed57ed351f6"

  def example(%Schema{type: :string}), do: ""
  def example(%Schema{type: :integer} = s), do: example_for(s, :integer)
  def example(%Schema{type: :number} = s), do: example_for(s, :number)
  def example(%Schema{type: :boolean}), do: false
  def example(schema_module) when is_atom(schema_module), do: example(schema_module.schema())
  def example(_schema), do: nil

  defp default(schema_module) when is_atom(schema_module), do: schema_module.schema().default
  defp default(%{default: default}), do: default
  defp default(%Reference{}), do: nil

  defp default(value) do
    raise "Expected %Schema{}, schema module, or %Reference{}. Got: #{inspect(value)}"
  end

  defp example_for(schemas, type) when type in [:anyOf, :allOf] do
    # Only handles :object schemas for now
    schemas
    |> Enum.map(&example/1)
    |> Enum.reduce(%{}, &Map.merge/2)
  end

  defp example_for(%{minimum: min} = schema, type)
       when type in [:number, :integer] and not is_nil(min) do
    if schema.exclusiveMinimum do
      min + 1
    else
      min
    end
  end

  defp example_for(%{maximum: max} = schema, type)
       when type in [:number, :integer] and not is_nil(max) do
    if schema.exclusiveMaximum do
      max - 1
    else
      max
    end
  end

  defp example_for(%{format: format}, type)
       when type in [:number, :integer] and format in [:float, :double],
       do: 0.0

  defp example_for(_schema, type) when type in [:number, :integer], do: 0
end