defmodule SafeJson do @moduledoc """ Thin wrapper around a JSON library. This module is a single chokepoint for JSON serialization in the app. It: * Sanitizes binaries before encoding to avoid errors on invalid UTF-8. * Translates backend-specific encode/decode errors into stable, backend-agnostic error tuples. ## Struct serialization Structs that need JSON encoding should implement the `SafeJson.Serialize` protocol rather than using `@derive {Jason.Encoder, ...}`. This keeps JSON serialization concerns inside SafeJson and avoids leaking the backend library into consuming modules. defimpl SafeJson.Serialize, for: MyStruct do def for_json(%MyStruct{name: name, age: age}) do %{name: name, age: age} end end """ # --------------------------------------------------------------------------- # Encode # --------------------------------------------------------------------------- @type decode_error :: {:invalid_json, String.t()} @type encode_error :: {:invalid_json, String.t()} @spec encode(term) :: {:ok, String.t()} | {:error, encode_error} def encode(term) do Jason.encode(sanitize(term)) |> case do {:ok, json} -> {:ok, json} {:error, reason} -> {:error, {:invalid_json, error_message(reason)}} end rescue e -> {:error, {:invalid_json, error_message(e)}} end @spec encode(term, keyword) :: {:ok, String.t()} | {:error, encode_error} def encode(term, opts) do Jason.encode(sanitize(term), opts) |> case do {:ok, json} -> {:ok, json} {:error, reason} -> {:error, {:invalid_json, error_message(reason)}} end rescue e -> {:error, {:invalid_json, error_message(e)}} end @spec encode!(term) :: String.t() def encode!(term) do case encode(term) do {:ok, json} -> json {:error, {:invalid_json, reason}} -> raise("JSON encode failed: #{reason}") end end @spec encode!(term, keyword) :: String.t() def encode!(term, opts) do case encode(term, opts) do {:ok, json} -> json {:error, {:invalid_json, reason}} -> raise("JSON encode failed: #{reason}") end end # --------------------------------------------------------------------------- # Decode # --------------------------------------------------------------------------- @spec decode(binary) :: {:ok, term} | {:error, decode_error} def decode(input) do Jason.decode(input) |> case do {:ok, decoded} -> {:ok, decoded} {:error, reason} -> {:error, {:invalid_json, error_message(reason)}} end rescue e -> {:error, {:invalid_json, error_message(e)}} end @spec decode(binary, keyword) :: {:ok, term} | {:error, decode_error} def decode(input, opts) do Jason.decode(input, opts) |> case do {:ok, decoded} -> {:ok, decoded} {:error, reason} -> {:error, {:invalid_json, error_message(reason)}} end rescue e -> {:error, {:invalid_json, error_message(e)}} end @spec decode!(binary) :: term def decode!(input) do case decode(input) do {:ok, decoded} -> decoded {:error, {:invalid_json, reason}} -> raise("JSON decode failed: #{reason}") end end @spec decode!(binary, keyword) :: term def decode!(input, opts) do case decode(input, opts) do {:ok, decoded} -> decoded {:error, {:invalid_json, reason}} -> raise("JSON decode failed: #{reason}") end end defp error_message(reason) do try do Exception.message(reason) rescue _ -> to_string(reason) end end # --------------------------------------------------------------------------- # Sanitization # # Recursively walks the data structure and replaces invalid UTF-8 sequences # in every binary value with the Unicode replacement character (U+FFFD). # # Structs that implement SafeJson.Serialize are converted to plain maps via # for_json/1, then sanitized normally. Structs without the protocol are passed # through unchanged -- Jason will raise if it can't encode them, which is the # desired behavior (forces you to implement the protocol). # --------------------------------------------------------------------------- defp sanitize(value) when is_binary(value), do: String.replace_invalid(value) defp sanitize(value) when is_struct(value) do if SafeJson.Serialize.impl_for(value) do value |> SafeJson.Serialize.for_json() |> sanitize() else value end end defp sanitize(value) when is_map(value) do Map.new(value, fn {k, v} -> {sanitize(k), sanitize(v)} end) end defp sanitize(value) when is_list(value), do: Enum.map(value, &sanitize/1) defp sanitize(value) when is_tuple(value) do value |> Tuple.to_list() |> Enum.map(&sanitize/1) |> List.to_tuple() end defp sanitize(value), do: value end defprotocol SafeJson.Serialize do @moduledoc """ Protocol for converting structs to JSON-safe plain maps. Implement this instead of `@derive {Jason.Encoder, ...}` to keep the JSON backend as an internal detail of `SafeJson`. """ @doc """ Returns a plain map (or other JSON-encodable term) representing the struct. The returned value will be recursively sanitized by SafeJson before encoding. """ @spec for_json(t) :: term def for_json(value) end