defmodule AutoStruct.JsonSchema do
@moduledoc """
Generates structs and conversion helpers from JSON Schema.
`AutoStruct.JsonSchema` is a compile-time macro. It reads either an inline
schema with `:schema` or a schema file with `:file`, generates a struct from
the schema's top-level `properties`, and delegates validation to Exonerate.
File-based schemas are useful when the schema uses local references or should
be shared with other tools:
defmodule Person do
use AutoStruct.JsonSchema, file: "priv/schemas/person.json"
end
Inline schemas are useful for small modules and tests:
iex> defmodule Elixir.AutoStruct.DocInlinePerson do
...> use AutoStruct.JsonSchema,
...> schema: \"\"\"
...> {
...> "type": "object",
...> "properties": {
...> "first_name": { "type": "string" },
...> "age": { "type": "integer" }
...> },
...> "required": ["first_name"]
...> }
...> \"\"\"
...> end
iex> {:ok, created} = AutoStruct.DocInlinePerson.new(first_name: "Ada", age: 36)
iex> created.first_name
"Ada"
iex> {:ok, loaded} = AutoStruct.DocInlinePerson.from_json(%{"first_name" => "Ada", "age" => 36})
iex> loaded.age
36
iex> {:ok, encoded} = AutoStruct.DocInlinePerson.new(first_name: "Ada", age: 36)
iex> AutoStruct.DocInlinePerson.to_json(encoded)
%{"age" => 36, "first_name" => "Ada"}
iex> {:error, {:validation_failed, _}} = AutoStruct.DocInlinePerson.validate(struct(AutoStruct.DocInlinePerson, first_name: 123))
The same API is generated for file-based schemas:
defmodule Person do
use AutoStruct.JsonSchema,
file: "examples/schemas/person.json"
end
The generated module includes:
* `new/1` and `new!/1` for building a validated struct from atom-keyed attrs.
* `from_json/1` and `from_json!/1` for building a validated struct from a
string-keyed JSON map.
* `to_json/1` for converting a generated struct back to a string-keyed map.
* `validate/1` for validating an existing generated struct.
* `__schema__/1` for compile-time schema metadata.
Nested JSON Schema objects and arrays are validated by Exonerate, but only the
top-level schema object is cast into a struct. Nested objects remain maps
unless the caller transforms them separately.
Generated structs implement Elixir's built-in `JSON.Encoder`. When Jason is
available at compile time, AutoStruct also emits a compatible `Jason.Encoder`
implementation.
"""
@valid_options [:file, :schema]
defmacro __using__(opts) do
opts = validate_options!(opts)
has_file = Keyword.has_key?(opts, :file)
has_schema = Keyword.has_key?(opts, :schema)
schema_file = if has_file, do: opts[:file]
{schema, schema_string} =
cond do
has_file and has_schema ->
raise ArgumentError, "Provide either :file or :schema, not both"
has_file ->
schema_string = File.read!(opts[:file])
schema = JSON.decode!(schema_string)
{schema, schema_string}
has_schema ->
parse_schema_option!(opts[:schema])
true ->
raise ArgumentError, "You must provide either :file or :schema in options"
end
struct_module = __CALLER__.module
properties = schema["properties"] || %{}
required = MapSet.new(schema["required"] || [])
field_mappings =
for {prop, opts} <- properties do
field = String.to_atom(prop)
default = Map.get(opts, "default", nil)
{field, prop, default}
end
enforce_keys =
for {field, prop, _default} <- field_mappings, MapSet.member?(required, prop) do
field
end
fields =
for {field, _prop, default} <- field_mappings do
{field, default}
end
field_types =
for {prop, opts} <- properties do
key = prop |> String.to_atom()
type_ast =
case opts["type"] do
"string" ->
quote(do: String.t())
"integer" ->
quote(do: integer())
"number" ->
quote(do: number())
"boolean" ->
quote(do: boolean())
"object" ->
quote(do: map())
"array" ->
# if you want to inspect opts["items"] you can
quote(do: [any()])
_ ->
quote(do: any())
end
{key, type_ast}
end
# build the %__MODULE__{ … } AST for the typespec
struct_type_ast =
{:%, [],
[
{:__MODULE__, [], Elixir},
{:%{}, [], Enum.map(field_types, fn {name, type} -> {name, type} end)}
]}
quoted =
quote do
unquote(
if schema_file do
quote do
@external_resource unquote(schema_file)
end
end
)
@enforce_keys unquote(enforce_keys)
@field_mappings unquote(for {field, prop, _default} <- field_mappings, do: {field, prop})
@field_to_json Map.new(@field_mappings)
@json_to_field Map.new(@field_mappings, fn {field, json_key} -> {json_key, field} end)
defstruct unquote(fields)
require Exonerate
unquote(
if schema_file do
quote do
Exonerate.function_from_file(
:def,
:_validate,
unquote(schema_file),
format: :default
)
end
else
quote do
Exonerate.function_from_string(
:def,
:_validate,
unquote(schema_string),
format: :default
)
end
end
)
@typedoc "Auto-generated struct type"
@type t() :: unquote(struct_type_ast)
@typedoc "Keyword list of attributes to build the struct"
@type attrs() :: [
unquote_splicing(
for {field, type_ast} <- field_types do
quote do: {unquote(field), unquote(type_ast)}
end
)
]
@doc """
Returns compile-time schema metadata for the generated module.
"""
def __schema__(:json), do: unquote(Macro.escape(schema))
def __schema__(:fields), do: @field_mappings
def __schema__(:required), do: unquote(enforce_keys)
def __schema__(key) do
raise ArgumentError,
"unknown schema metadata #{inspect(key)}; expected :json, :fields, or :required"
end
@doc """
Creates a validated struct from atom-keyed attributes.
Attributes are normalized to JSON-shaped data before validation, so
nested structs, maps, lists, and atom keys can be validated against the
generated JSON Schema validator.
Returns `{:ok, struct}` or `{:error, reason}`.
"""
@spec new(attrs()) :: {:ok, t()} | {:error, any()}
def new(attrs) when is_list(attrs) do
json = attrs_to_json(attrs)
case _validate(json) do
:ok -> {:ok, struct(__MODULE__, attrs)}
{:error, reason} -> {:error, reason}
end
end
@doc """
Creates a validated struct from atom-keyed attributes.
Raises when validation fails.
"""
@spec new!(attrs()) :: t()
def new!(attrs) when is_list(attrs) do
case new(attrs) do
{:ok, struct} -> struct
{:error, reason} -> raise "Validation failed: \\#{inspect(reason)}"
end
end
@doc """
Validates an existing generated struct against its JSON Schema.
The struct is converted to a JSON-shaped map before validation. Fields
with `nil` values are omitted from the validation payload.
"""
@spec validate(t()) :: :ok | {:error, any()}
def validate(%__MODULE__{} = struct) do
json =
for {field, json_key} <- @field_mappings,
value = Map.fetch!(struct, field),
not is_nil(value),
into: %{} do
{json_key, normalize_json_value(value)}
end
case _validate(json) do
:ok -> :ok
{:error, details} -> {:error, {:validation_failed, details}}
end
end
@doc """
Creates a validated struct from a string-keyed JSON map.
Validation runs on the original JSON-shaped map before the top-level
fields are cast into the generated struct. Nested objects remain maps.
Returns `{:ok, struct}` or `{:error, reason}`.
"""
@spec from_json(map()) :: {:ok, t()} | {:error, any()}
def from_json(map) when is_map(map) do
case _validate(map) do
:ok -> {:ok, struct(__MODULE__, json_to_attrs(map))}
{:error, reason} -> {:error, reason}
end
end
@doc """
Creates a validated struct from a string-keyed JSON map.
Raises when validation fails.
"""
@spec from_json!(map()) :: t()
def from_json!(map) when is_map(map) do
case from_json(map) do
{:ok, struct} -> struct
{:error, reason} -> raise "Validation failed: \\#{inspect(reason)}"
end
end
@doc """
Converts a generated struct to a string-keyed JSON map.
Nested generated structs are converted through their own `to_json/1`
function when available. Maps, lists, and atom keys are recursively
normalized into JSON-shaped data.
"""
@spec to_json(t()) :: map()
def to_json(%__MODULE__{} = struct) do
for {field, json_key} <- @field_mappings, into: %{} do
{json_key, normalize_json_value(Map.fetch!(struct, field))}
end
end
defp attrs_to_json(attrs) do
for {field, value} <- attrs, into: %{} do
json_key = Map.get(@field_to_json, field, normalize_json_key(field))
{json_key, normalize_json_value(value)}
end
end
defp json_to_attrs(map) do
for {json_key, field} <- @json_to_field,
Map.has_key?(map, json_key),
into: %{} do
{field, Map.fetch!(map, json_key)}
end
end
defp normalize_json_value(%module{} = struct) do
if function_exported?(module, :to_json, 1) do
module.to_json(struct)
else
struct
end
end
defp normalize_json_value(map) when is_map(map) do
for {key, value} <- map, into: %{} do
{normalize_json_key(key), normalize_json_value(value)}
end
end
defp normalize_json_value(list) when is_list(list) do
Enum.map(list, &normalize_json_value/1)
end
defp normalize_json_value(value), do: value
defp normalize_json_key(key) when is_atom(key), do: Atom.to_string(key)
defp normalize_json_key(key), do: key
defimpl JSON.Encoder, for: unquote(struct_module) do
def encode(value, encoder) do
value
|> unquote(struct_module).to_json()
|> JSON.Encoder.Map.encode(encoder)
end
end
if Code.ensure_loaded?(Jason.Encoder) do
defimpl Jason.Encoder, for: unquote(struct_module) do
def encode(value, opts) do
value
|> unquote(struct_module).to_json()
|> Jason.Encode.map(opts)
end
end
end
end
quoted
end
defp validate_options!(opts) when is_list(opts) do
unless Keyword.keyword?(opts) do
raise ArgumentError, "AutoStruct.JsonSchema options must be a keyword list"
end
unknown_options = Keyword.keys(opts) -- @valid_options
if unknown_options != [] do
raise ArgumentError,
"unknown AutoStruct.JsonSchema option(s): #{inspect(unknown_options)}; expected :file or :schema"
end
if Keyword.has_key?(opts, :file) and not is_binary(opts[:file]) do
raise ArgumentError, "AutoStruct.JsonSchema :file option must be a string path"
end
opts
end
defp validate_options!(_opts) do
raise ArgumentError, "AutoStruct.JsonSchema options must be a keyword list"
end
defp parse_schema_option!(schema_string) when is_binary(schema_string) do
{JSON.decode!(schema_string), schema_string}
end
defp parse_schema_option!({term, _meta, [{:<<>>, _bin_meta, [schema_string]}, _mods]})
when term in [:sigil_j, :sigil_J] and is_binary(schema_string) do
{JSON.decode!(schema_string), schema_string}
end
defp parse_schema_option!(_schema) do
raise ArgumentError,
"AutoStruct.JsonSchema :schema option must be a JSON string or a literal ~j/~J sigil"
end
end