lib/jsonschema.ex

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