Packages

fnord

0.9.19

AI code archaeology

Current section

Files

Jump to
fnord lib safe_json.ex
Raw

lib/safe_json.ex

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
# ---------------------------------------------------------------------------
# Lenient decode
#
# LLM responses nominally constrained by response_format can still arrive
# wrapped in markdown code fences or prefixed with prose. These helpers strip
# that noise before decoding, making structured-output parsing more robust.
# ---------------------------------------------------------------------------
@doc """
Like `decode/1`, but strips markdown code fences and leading prose before
decoding. Use when parsing LLM responses that should be JSON but may be
wrapped in fences or prefixed with text.
"""
@spec decode_lenient(binary | nil) :: {:ok, term} | {:error, decode_error}
def decode_lenient(nil), do: {:error, {:invalid_json, "nil input"}}
def decode_lenient(input) when is_binary(input) do
input
|> strip_code_fences()
|> extract_json_object()
|> decode()
end
@doc """
Like `decode/2`, but strips markdown code fences and leading prose before
decoding.
"""
@spec decode_lenient(binary | nil, keyword) :: {:ok, term} | {:error, decode_error}
def decode_lenient(nil, _opts), do: {:error, {:invalid_json, "nil input"}}
def decode_lenient(input, opts) when is_binary(input) do
input
|> strip_code_fences()
|> extract_json_object()
|> decode(opts)
end
# Remove wrapping ```json ... ``` or ``` ... ``` fences.
defp strip_code_fences(text) do
text
|> String.trim()
|> String.replace(~r/^```json\s*/i, "")
|> String.replace(~r/^```\s*/, "")
|> String.replace(~r/\s*```$/, "")
end
# Drop any prefix before the first '{' so prose preamble doesn't break parsing.
defp extract_json_object(text) do
case String.split(text, "{", parts: 2) do
[_, rest] -> "{" <> rest
_ -> text
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