lib/google_api/gax/model_base.ex

# Copyright 2018 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

defmodule GoogleApi.Gax.ModelBase do
  require Poison.Decode

  @moduledoc """
  This module helps you quickly and concisely define API models.

  Example:

      defmodule Pet do
        use GoogleApi.Gax.ModelBase

        field(:id)
        field(:category, as: Category)
        field(:tags, as: Tag, type: :list)
      end
  """
  defmacro __using__(_opts) do
    quote do
      import unquote(__MODULE__)

      @fields []

      @before_compile unquote(__MODULE__)
    end
  end

  defmacro __before_compile__(_env) do
    quote do
      defstruct Keyword.keys(@fields)

      @doc """
      Unwrap a decoded JSON object into its complex fields.
      """
      @spec decode(struct(), keyword()) :: struct()
      def decode(value, _options) do
        Enum.reduce(@fields, value, fn {field_name, opts}, v ->
          if struct = Keyword.get(opts, :as) do
            Map.update!(v, field_name, fn current ->
              type = Keyword.get(opts, :type, :primitive)
              struct = Keyword.get(opts, :as)
              GoogleApi.Gax.ModelBase.decode(current, type, struct)
            end)
          else
            v
          end
        end)
      end
    end
  end

  defmacro field(field_name, opts \\ []) do
    quote do
      @fields [{unquote(field_name), unquote(opts)} | @fields]
    end
  end

  @doc """
  Helper to decode model fields
  """
  @spec decode(struct(), :list | :listlist | :map | :primitive, nil | module()) :: struct()
  def decode(nil, _, _) do
    nil
  end

  def decode(value, _, nil) do
    value
  end

  def decode(value, :listlist, module) do
    Enum.map(value, &(decode(&1, :list, module)))
  end

  def decode(value, :list, DateTime) do
    Enum.map(value, &parse_date_time/1)
  end

  def decode(value, _, DateTime) do
    parse_date_time(value)
  end

  def decode(value, :list, Date) do
    Enum.map(value, &parse_date/1)
  end

  def decode(value, _, Date) do
    parse_date(value)
  end

  def decode(value, :map, module) when not is_nil(value) do
    value
    |> Enum.map(fn {k, v} ->
      {k, poison_transform(v, %{as: struct(module)})}
    end)
    |> Enum.into(%{})
  end

  def decode(value, :list, module) do
    poison_transform(value, %{as: [struct(module)]})
  end

  def decode(value, _, module) do
    poison_transform(value, %{as: struct(module)})
  end

  if function_exported?(Poison.Decode, :decode, 2) do
    def poison_transform(value, options) do
      Poison.Decode.decode(value, options)
    end
  else
    # Short-circuit if the value has already been transformed.
    # This works around a bug in poison 4 where Poison.decode does an extra
    # transformation pass on sub-structs.
    def poison_transform(%{__struct__: _} = value, %{as: _}) do
      value
    end

    def poison_transform([%{__struct__: _} | _] = value, %{as: [_]}) do
      value
    end

    def poison_transform(value, options) do
      Poison.Decode.transform(value, options)
    end
  end

  @doc """
  Helper to encode model into JSON
  """
  @spec encode(struct(), keyword()) :: String.t()
  def encode(value, options) do
    value
    |> Map.from_struct()
    |> Enum.filter(fn {_k, v} -> v != nil end)
    |> Enum.into(%{})
    |> Poison.Encoder.encode(options)
  end

  defp parse_date(nil), do: nil

  defp parse_date(%Date{} = date), do: date

  defp parse_date(ymd) do
    case Date.from_iso8601(ymd) do
      {:ok, date} -> date
      _ -> ymd
    end
  end

  defp parse_date_time(nil), do: nil

  defp parse_date_time(%DateTime{} = date_time), do: date_time

  defp parse_date_time(iso8601) do
    case DateTime.from_iso8601(iso8601) do
      {:ok, datetime, _offset} -> datetime
      _ -> iso8601
    end
  end
end