Packages
fnord
0.9.31
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
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