lib/open_api_spex.ex

defmodule OpenApiSpex do
  @moduledoc """
  Provides the entry-points for defining schemas, validating and casting.
  """

  alias OpenApiSpex.{
    Components,
    OpenApi,
    Operation,
    Operation2,
    Reference,
    Schema,
    SchemaConsistency,
    SchemaException,
    SchemaResolver
  }

  alias OpenApiSpex.Cast.Error

  @doc """
  Adds schemas to the api spec from the modules specified in the Operations.

  Eg, if the response schema for an operation is defined with:

      responses: %{
        200 => Operation.response("User", "application/json", UserResponse)
      }

  Then the `UserResponse.schema()` function will be called to load the schema, and
  a `Reference` to the loaded schema will be used in the operation response.

  See `OpenApiSpex.schema` macro for a convenient syntax for defining schema modules.
  """
  @spec resolve_schema_modules(OpenApi.t()) :: OpenApi.t()
  def resolve_schema_modules(spec = %OpenApi{}) do
    SchemaResolver.resolve_schema_modules(spec)
  end

  @doc """
  Add more schemas to an existing OpenApi struct.

  This is useful when the schemas are not used by a Plug or Controller.
  Perhaps they're used by a Phoenix Channel.

  ## Example

      defmodule MyAppWeb.ApiSpec do
        def spec do
          %OpenApiSpex.OpenApi{
            # ...
            paths: OpenApiSpex.Paths.from_router(MyAppWeb.Router)
            # ...
          }
          |> OpenApiSpex.resolve_schema_modules()
          |> OpenApiSpex.add_schemas([
            MyAppWeb.Schemas.Message,
            MyAppWeb.Schemas.Channel
          ])
        end
      end
  """
  @spec add_schemas(OpenApi.t(), list(module)) :: OpenApi.t()
  defdelegate add_schemas(spec, schema_modules), to: SchemaResolver

  @doc """
  Cast and validate a value against a given Schema.
  """
  def cast_value(value, schema = %schema_mod{}) when schema_mod in [Schema, Reference] do
    OpenApiSpex.Cast.cast(schema, value)
  end

  @doc """
  Cast and validate a value against a given Schema belonging to a given OpenApi spec.
  """
  def cast_value(value, schema = %schema_mod{}, spec = %OpenApi{})
      when schema_mod in [Schema, Reference] do
    OpenApiSpex.Cast.cast(schema, value, spec.components.schemas)
  end

  @type cast_opt :: {:replace_params, boolean()} | {:apply_defaults, boolean()}

  @spec cast_and_validate(
          OpenApi.t(),
          Operation.t(),
          Plug.Conn.t(),
          content_type :: nil | String.t(),
          opts :: [cast_opt()]
        ) :: {:error, [Error.t()]} | {:ok, Plug.Conn.t()}
  def cast_and_validate(
        spec = %OpenApi{},
        operation = %Operation{},
        conn = %Plug.Conn{},
        content_type \\ nil,
        opts \\ []
      ) do
    content_type = content_type || content_type_from_header(conn)
    Operation2.cast(spec, operation, conn, content_type, opts)
  end

  defp content_type_from_header(conn = %Plug.Conn{}) do
    case Plug.Conn.get_req_header(conn, "content-type") do
      [header_value | _] ->
        header_value
        |> String.split(";")
        |> List.first()

      _ ->
        nil
    end
  end

  @doc """
  Cast params to conform to a `OpenApiSpex.Schema`.

  See `OpenApiSpex.Schema.cast/3` for additional examples and details.
  """
  @spec cast(OpenApi.t(), Schema.t() | Reference.t(), any) :: {:ok, any} | {:error, String.t()}
  @deprecated "Use OpenApiSpex.cast_value/3 or cast_value/2 instead"
  def cast(spec = %OpenApi{}, schema = %Schema{}, params) do
    Schema.cast(schema, params, spec.components.schemas)
  end

  def cast(spec = %OpenApi{}, schema = %Reference{}, params) do
    Schema.cast(schema, params, spec.components.schemas)
  end

  @doc """
  Cast all params in `Plug.Conn` to conform to the schemas for `OpenApiSpex.Operation`.

  Returns `{:ok, Plug.Conn.t}` with `params` and `body_params` fields updated if successful,
  or `{:error, reason}` if casting fails.

  `content_type` may optionally be supplied to select the `requestBody` schema.
  """
  @spec cast(OpenApi.t(), Operation.t(), Plug.Conn.t(), content_type | nil) ::
          {:ok, Plug.Conn.t()} | {:error, String.t()}
        when content_type: String.t()
  @deprecated "Use OpenApiSpex.cast_and_validate/3 instead"
  def cast(spec = %OpenApi{}, operation = %Operation{}, conn = %Plug.Conn{}, content_type \\ nil) do
    Operation.cast(operation, conn, content_type, spec.components.schemas)
  end

  @doc """
  Validate params against `OpenApiSpex.Schema`.

  See `OpenApiSpex.Schema.validate/3` for examples of error messages.
  """
  @spec validate(OpenApi.t(), Schema.t() | Reference.t(), any) :: :ok | {:error, String.t()}
  @deprecated "Use OpenApiSpex.cast_value/3 or cast_value/2 instead"
  def validate(spec = %OpenApi{}, schema = %Schema{}, params) do
    Schema.validate(schema, params, spec.components.schemas)
  end

  def validate(spec = %OpenApi{}, schema = %Reference{}, params) do
    Schema.validate(schema, params, spec.components.schemas)
  end

  @doc """
  Validate all params in `Plug.Conn` against `OpenApiSpex.Operation` `parameter` and `requestBody` schemas.

  `content_type` may be optionally supplied to select the `requestBody` schema.
  """
  @spec validate(OpenApi.t(), Operation.t(), Plug.Conn.t(), content_type | nil) ::
          :ok | {:error, String.t()}
        when content_type: String.t()
  @deprecated "Use OpenApiSpex.cast_and_validate/4 instead"
  def validate(
        spec = %OpenApi{},
        operation = %Operation{},
        conn = %Plug.Conn{},
        content_type \\ nil
      ) do
    Operation.validate(operation, conn, content_type, spec.components.schemas)
  end

  def path_to_string(%Error{} = error) do
    Error.path_to_string(error)
  end

  def error_message(%Error{} = error) do
    Error.message(error)
  end

  @doc """
  Declares a struct based `OpenApiSpex.Schema`

   - defines the schema/0 callback
   - ensures the schema is linked to the module by "x-struct" extension property
   - defines a struct with keys matching the schema properties
   - defines a @type `t` for the struct
   - derives a `Jason.Encoder` and/or `Poison.Encoder` for the struct

  See `OpenApiSpex.Schema` for additional examples and details.

  ## Example

      require OpenApiSpex

      defmodule User do
        OpenApiSpex.schema %{
          title: "User",
          description: "A user of the app",
          type: :object,
          properties: %{
            id: %Schema{type: :integer, description: "User ID"},
            name:  %Schema{type: :string, description: "User name", pattern: ~r/[a-zA-Z][a-zA-Z0-9_]+/},
            email: %Schema{type: :string, description: "Email address", format: :email},
            inserted_at: %Schema{type: :string, description: "Creation timestamp", format: :'date-time'},
            updated_at: %Schema{type: :string, description: "Update timestamp", format: :'date-time'}
          },
          required: [:name, :email],
          example: %{
            "id" => 123,
            "name" => "Joe User",
            "email" => "joe@gmail.com",
            "inserted_at" => "2017-09-12T12:34:55Z",
            "updated_at" => "2017-09-13T10:11:12Z"
          }
        }
      end

  ## Example

  This example shows the `:struct?` and `:derive?` options that may
  be passed to `schema/2`:

      defmodule MyAppWeb.Schemas.User do
        require OpenApiSpex
        alias OpenApiSpex.Schema

        OpenApiSpex.schema(
          %{
            type: :object,
            properties: %{
              name: %Schema{type: :string}
            }
          },
          struct?: false,
          derive?: false
        )
      end

  ## Options

  - `:struct?` (boolean) - When false, prevents the automatic generation
    of a struct definition for the schema module.
  - `:derive?` (boolean) When false, prevents the automatic generation
    of a `@derive` call for either `Poison.Encoder`
    or `Jason.Encoder`. Using this option can
    prevent "... protocol has already been consolidated ..."
    compiler warnings.
  """
  defmacro schema(body, opts \\ []) do
    quote do
      @compile {:report_warnings, false}
      @behaviour OpenApiSpex.Schema
      @schema OpenApiSpex.build_schema(
                unquote(body),
                Keyword.merge([module: __MODULE__], unquote(opts))
              )

      def schema, do: @schema

      if Map.get(@schema, :"x-struct") == __MODULE__ do
        if Keyword.get(unquote(opts), :derive?, true) do
          @derive Enum.filter([Poison.Encoder, Jason.Encoder], &Code.ensure_loaded?/1)
        end

        if Keyword.get(unquote(opts), :struct?, true) do
          defstruct Schema.properties(@schema)
          @type t :: %__MODULE__{}
        end
      end
    end
  end

  @doc """
  Build a Schema struct from the given keyword list.

  This function adds functionality over defining a
  schema with struct literal sytax using `%OpenApiSpex.Struct{}`:

  - When the `:module` option is given, the `:"x-struct` and `:title`
    attributes of the schema will be autopopulated based on the given
    module
  - Validations are performed on the schema to ensure it is correct.

  ## Options

  - `:module` (module) - A module in the application that the schema
    should be associated with.
  """
  def build_schema(body, opts \\ []) do
    module = opts[:module] || body[:"x-struct"]

    attrs =
      body
      |> Map.delete(:__struct__)
      |> update_in([:"x-struct"], fn struct_module ->
        if Keyword.get(opts, :struct?, true) do
          struct_module || module
        else
          struct_module
        end
      end)
      |> update_in([:title], fn title ->
        title || title_from_module(module)
      end)

    schema = struct(OpenApiSpex.Schema, attrs)

    Map.from_struct(schema) |> OpenApiSpex.validate_compiled_schema()

    # Throwing warnings to prevent runtime bugs (like in issue #144)
    schema
    |> SchemaConsistency.warnings()
    |> Enum.each(&IO.warn("Inconsistent schema: #{&1}", Macro.Env.stacktrace(__ENV__)))

    schema
  end

  def title_from_module(nil), do: nil

  def title_from_module(module) do
    module |> Module.split() |> List.last()
  end

  @doc """
  Creates an `%OpenApi{}` struct from a map.

  This is useful when importing existing JSON or YAML encoded schemas.

  ## Example
      # Importing an existing JSON encoded schema
      open_api_spec_from_json = "encoded_schema.json"
        |> File.read!()
        |> Jason.decode!()
        |> OpenApiSpex.OpenApi.Decode.decode()

      # Importing an existing YAML encoded schema
      open_api_spec_from_yaml = "encoded_schema.yaml"
        |> YamlElixir.read_all_from_file!()
        |> OpenApiSpex.OpenApi.Decode.decode()
  """
  def schema_from_map(map), do: OpenApiSpex.OpenApi.from_map(map)

  @doc """
  Validate the compiled schema's properties to ensure the schema is not improperly
  defined. Only errors which would cause a given schema to _always_ fail should be
  raised here.
  """
  def validate_compiled_schema(schema) do
    Enum.each(schema, fn prop_and_val ->
      :ok = validate_compiled_schema(prop_and_val, schema)
    end)
  end

  def validate_compiled_schema({_, %Schema{} = schema}, _parent) do
    validate_compiled_schema(Map.from_struct(schema))
  end

  @doc """
  Used for validating the schema at compile time, otherwise we're forced
  to raise errors for improperly defined schemas at runtime.
  """
  def validate_compiled_schema({:discriminator, %{propertyName: property, mapping: _}}, %{
        anyOf: schemas
      })
      when is_list(schemas) do
    Enum.each(schemas, fn schema ->
      case schema do
        %Schema{title: title} when is_binary(title) -> :ok
        _ -> error!(:discriminator_schema_missing_title, schema, property_name: property)
      end
    end)
  end

  def validate_compiled_schema({:discriminator, %{propertyName: _, mapping: _}}, schema) do
    case {schema.anyOf, schema.allOf, schema.oneOf} do
      {nil, nil, nil} ->
        error!(:discriminator_missing_composite_key, schema)

      _ ->
        :ok
    end
  end

  def validate_compiled_schema({_property, _value}, _schema), do: :ok

  @doc """
  Raises compile time errors for improperly defined schemas.
  """
  @spec error!(atom(), Schema.t(), keyword()) :: no_return()
  @spec error!(atom(), Schema.t()) :: no_return()
  def error!(error, schema, details \\ []) do
    raise SchemaException, %{error: error, schema: schema, details: details}
  end

  @doc """
  Resolve a schema or reference to a schema.
  """
  @spec resolve_schema(Schema.t() | Reference.t() | module, Components.schemas_map()) ::
          Schema.t()
  def resolve_schema(%Schema{} = schema, _), do: schema
  def resolve_schema(%Reference{} = ref, schemas), do: Reference.resolve_schema(ref, schemas)

  def resolve_schema(mod, _) when is_atom(mod) do
    IO.warn("""
    Unresolved schema module: #{inspect(mod)}.
    Use OpenApiSpex.resolve_schema_modules/1 to resolve modules ahead of time.
    """)

    mod.schema()
  end

  @doc """
  Get casted body params from a `Plug.Conn`.
  If the conn has not been yet casted `nil` is returned.
  """
  @spec body_params(Plug.Conn.t()) :: nil | map()
  def body_params(%Plug.Conn{} = conn), do: get_in(conn.private, [:open_api_spex, :body_params])

  @doc """
  Get casted params from a `Plug.Conn`.
  If the conn has not been yet casted `nil` is returned.
  """
  @spec params(Plug.Conn.t()) :: nil | map()
  def params(%Plug.Conn{} = conn), do: get_in(conn.private, [:open_api_spex, :params])

  @doc """

  """
  @spec add_parameter_content_parser(
          OpenApi.t(),
          content_type | [content_type],
          parser :: module()
        ) :: OpenApi.t()
        when content_type: String.t() | Regex.t()
  def add_parameter_content_parser(%OpenApi{extensions: ext} = spec, content_type, parser) do
    extensions = ext || %{}

    param_parsers = Map.get(extensions, "x-parameter-content-parsers", %{})

    param_parsers =
      content_type
      |> List.wrap()
      |> Enum.reduce(param_parsers, fn ct, acc -> Map.put(acc, ct, parser) end)

    %OpenApi{spec | extensions: Map.put(extensions, "x-parameter-content-parsers", param_parsers)}
  end
end