defmodule Stripe.Deserializer do @moduledoc """ Deserializes JSON strings and raw maps into typed Stripe structs. Used internally by the client to cast API responses. Also provides `from_json/1` for manual deserialization of JSON payloads. """ alias Stripe.ObjectTypes @doc """ Decode a JSON string and cast into typed structs. """ @spec from_json(String.t(), keyword()) :: {:ok, term()} | {:error, term()} def from_json(json_string, opts \\ []) when is_binary(json_string) do case JSON.decode(json_string) do {:ok, data} -> {:ok, cast(data, opts)} {:error, _} = error -> error end end @spec cast(map() | list() | term(), keyword()) :: struct() | map() | list() | term() def cast(data, opts \\ []) # V1 list def cast(%{"object" => "list"} = data, opts) do items = data["data"] |> List.wrap() |> Enum.map(&cast(&1, opts)) %Stripe.ListObject{ object: data["object"], data: items, has_more: data["has_more"], url: data["url"] } end # V1 search result def cast(%{"object" => "search_result"} = data, opts) do items = data["data"] |> List.wrap() |> Enum.map(&cast(&1, opts)) %Stripe.SearchResult{ object: data["object"], data: items, has_more: data["has_more"], url: data["url"], next_page: data["next_page"], total_count: data["total_count"] } end # V2 list (detected by next_page_url key + data being a list) def cast(%{"next_page_url" => _, "data" => items} = data, opts) when is_list(items) do cast_items = Enum.map(items, &cast(&1, opts)) %Stripe.V2.ListObject{ data: cast_items, next_page_url: data["next_page_url"] } end # Object with known type def cast(%{"object" => object_name} = data, opts) do api_mode = Keyword.get(opts, :api_mode, :v1) case resolve_module(object_name, api_mode) do nil -> data module -> populate_struct(module, data, opts) end end # Map without "object" key — return raw def cast(%{} = data, _opts), do: data # Top-level list — cast each element def cast(data, opts) when is_list(data), do: Enum.map(data, &cast(&1, opts)) # Scalars (string, integer, nil, etc.) — passthrough def cast(data, _opts), do: data # -- Private ---------------------------------------------------------------- defp resolve_module(name, :v2) do Map.get(ObjectTypes.v2_object_name_to_module(), name) || Map.get(ObjectTypes.object_name_to_module(), name) end defp resolve_module(name, _v1) do Map.get(ObjectTypes.object_name_to_module(), name) end defp populate_struct(module, data, opts) do Code.ensure_loaded!(module) struct_keys = module.__struct__() |> Map.keys() |> List.delete(:__struct__) nested_fields = if function_exported?(module, :__nested_fields__, 0), do: module.__nested_fields__(), else: %{} to_struct(module, data, struct_keys, nested_fields, opts) end defp to_struct(module, data, struct_keys, nested_fields, opts) do struct_keys |> Enum.map(fn atom_key -> string_key = Atom.to_string(atom_key) raw = Map.get(data, string_key) {atom_key, cast_field(string_key, raw, nested_fields, opts)} end) |> then(&struct(module, &1)) end defp cast_field(_key, nil, _nested_fields, _opts), do: nil # Field has generated nested-shape metadata and the value is present. defp cast_field(key, raw, nested_fields, opts) when is_map_key(nested_fields, key) do nested_fields |> Map.fetch!(key) |> cast_with_metadata(raw, opts) end # Expanded object (map with "object" key, no nested metadata match) → recursive cast defp cast_field(_key, %{"object" => _} = raw, _nested_fields, opts) do cast(raw, opts) end # List without nested metadata → cast each element that has an "object" key defp cast_field(_key, raw, _nested_fields, opts) when is_list(raw) do Enum.map(raw, fn %{"object" => _} = item -> cast(item, opts) other -> other end) end # Scalar passthrough defp cast_field(_key, raw, _nested_fields, _opts), do: raw defp cast_with_metadata(_metadata, nil, _opts), do: nil defp cast_with_metadata({:list, metadata}, raw, opts) when is_list(raw) do Enum.map(raw, &cast_with_metadata(metadata, &1, opts)) end defp cast_with_metadata({:map, metadata}, raw, opts) when is_map(raw) do Map.new(raw, fn {key, value} -> {key, cast_with_metadata(metadata, value, opts)} end) end defp cast_with_metadata({:resource, module}, %{} = raw, opts) do populate_struct(module, raw, opts) end defp cast_with_metadata(%{fields: fields}, %{} = raw, opts) do known = fields |> Enum.map(fn {string_key, metadata} -> atom_key = String.to_atom(string_key) raw_value = Map.get(raw, string_key) {atom_key, cast_with_metadata(metadata, raw_value, opts)} end) |> Map.new() unknown = raw |> Enum.reject(fn {key, _value} -> Map.has_key?(fields, key) end) |> Map.new() Map.merge(unknown, known) end defp cast_with_metadata(:scalar, %{"object" => _} = raw, opts), do: cast(raw, opts) defp cast_with_metadata(_metadata, raw, _opts), do: raw end