lib/oasis/plug/request_validator.ex

defmodule Oasis.Plug.RequestValidator do
  @moduledoc ~S"""
  A plug to convert types and validate the HTTP request parameters by the schemas of
  the OpenAPI definition.

  The schema options can be found in the generated `pre-` plug handler file, the full list:

    * `:query_schema`
    * `:header_schema`
    * `:cookie_schema`
    * `:body_schema`

  All of these options are fully map and generated from the corresponding definition of the OpenAPI Specification.

  When the query parameters are verified by the validation of `:query_schema`, the coverted types of query parameters
  are reserved in `:query_params` and `:params` field of the `Plug.Conn`.

  When the header parameters are verified by the validation of `:header_schema`, the converted types of header parameters
  are reserved in `:req_headers` field of the `Plug.Conn`.

  When the cookie parameters are verified by the validation of `:cookie_schema`, the coverted types of cookie parameters
  are reserved in `:req_cookies` field of the `Plug.Conn`.

  When the request body is verified by the validation of `:body_schema`, the coverted types of request body are reserved
  in `:body_params` and `:params` field of the `Plug.Conn`.
  """

  import Plug.Conn

  @behaviour Plug

  require Logger

  def init(opts), do: opts

  def call(conn, opts) do
    conn
    |> process_query(opts[:query_schema])
    |> process_header(opts[:header_schema])
    |> process_cookie(opts[:cookie_schema])
    |> process_body(opts[:body_schema])
  end

  defp process_query(conn, query_schema) when is_map(query_schema) do
    conn = fetch_query_params(conn)

    %{query_params: query_params, path_params: path_params, params: params} = conn

    query_params = parse_and_validate(query_schema, query_params, "query")

    params = params |> Map.merge(query_params) |> Map.merge(path_params)

    %{conn | query_params: query_params, params: params}
  end

  defp process_query(conn, _) do
    conn
  end

  defp process_header(conn, header_schema) when is_map(header_schema) do
    header_params = parse_and_validate(header_schema, Map.new(conn.req_headers), "header")

    %{conn | req_headers: Map.to_list(header_params)}
  end

  defp process_header(conn, _) do
    conn
  end

  defp process_cookie(conn, cookie_schema) when is_map(cookie_schema) do
    conn = fetch_cookies(conn)

    cookie_params = parse_and_validate(cookie_schema, conn.req_cookies, "cookie")

    %{conn | req_cookies: cookie_params}
  end

  defp process_cookie(conn, _) do
    conn
  end

  defp process_body(%{body_params: %Plug.Conn.Unfetched{}} = conn, _) do
    # By default, we use `Plug` built-in parsers to process request body before
    # this function, if these built-in parsers do not match the request, we will
    # ignore the request to be formatted and validated.
    conn
  end

  defp process_body(
         %{body_params: %{"_json" => _body_params}, params: params} = conn,
         body_schema
       )
       when is_map(body_schema) do

    body_params = parse_and_validate_body_params(conn, body_schema)

    if is_map(body_params) do
      # Maybe the defined schema specification use the "_json" key as a property of an object.
      params = Map.merge(params, body_params)
      %{conn | body_params: body_params, params: params}
    else
      %{conn | body_params: body_params, params: body_params}
    end
  end

  defp process_body(
         %{params: params} = conn,
         body_schema
       )
       when is_map(body_schema) do

    body_params = parse_and_validate_body_params(conn, body_schema)

    params = Map.merge(params, body_params)

    %{conn | body_params: body_params, params: params}
  end

  defp process_body(conn, _) do
    conn
  end

  defp parse_and_validate_body_params(%{body_params: body_params, req_headers: req_headers}, body_schema) do
    req_headers
    |> find_content_type()
    |> schema_may_match_by_request(body_schema)
    |> Oasis.Validator.parse_and_validate!("body", "body_request", body_params)
  end

  defp parse_and_validate(schemas, input_params, use_in) when is_map(input_params) do
    Enum.reduce(schemas, input_params, fn {param_name, definition}, acc ->
      input_value = input_params[param_name]

      prepared_value =
        Oasis.Validator.parse_and_validate!(definition, use_in, param_name, input_value)

      if prepared_value == nil and input_value == nil do
        acc
      else
        Map.put(acc, param_name, prepared_value)
      end
    end)
  end

  defp find_content_type(req_headers) do
    case List.keyfind(req_headers, "content-type", 0) do
      {_, content} ->
        String.downcase(content)
      nil ->
        nil
    end
  end

  defp schema_may_match_by_request("multipart/mixed" <> _, definition) do
    schema_by_request_content_type("multipart/mixed", definition)
  end

  defp schema_may_match_by_request("multipart/form-data" <> _, definition) do
    schema_by_request_content_type("multipart/form-data", definition)
  end

  defp schema_may_match_by_request("text/plain" <> _charset, definition) do
    schema_by_request_content_type("text/plain", definition)
  end

  defp schema_may_match_by_request("application/json" <> _charset, definition) do
    schema_by_request_content_type("application/json", definition)
  end

  defp schema_may_match_by_request("application/x-www-form-urlencoded" <> _charset, definition) do
    schema_by_request_content_type("application/x-www-form-urlencoded", definition)
  end

  defp schema_may_match_by_request(content_type, definition) do
    schema_by_request_content_type(content_type, definition)
  end

  defp schema_by_request_content_type(content_type, %{"content" => content} = definition) do
    matched = Map.take(content, [content_type])

    if matched != %{} do
      %{definition | "content" => matched}
    else
      nil
    end
  end

  defp schema_by_request_content_type(_content_type, _definition) do
    nil
  end
end