defmodule ExJsonschema do @moduledoc """ High-performance JSON Schema validation for Elixir using Rust. Fast, spec-compliant JSON Schema validation powered by the Rust `jsonschema` crate with support for multiple draft versions and comprehensive error reporting. ## Quick Start # Compile and validate schema = ~s({"type": "object", "properties": {"name": {"type": "string"}}}) {:ok, validator} = ExJsonschema.compile(schema) :ok = ExJsonschema.validate(validator, ~s({"name": "John"})) # Get detailed errors {:error, errors} = ExJsonschema.validate(validator, ~s({"name": 123})) ExJsonschema.format_errors(errors, :human) ## Core Functions - `compile/1,2` - Compile JSON Schema for validation - `validate/2,3` - Validate JSON against compiled schema - `valid?/2,3` - Quick boolean validation check - `meta_validate/1` - Validate schema document itself - `format_errors/3` - Format validation errors for display - `analyze_errors/1,2` - Analyze error patterns and severity ## Output Formats - `:basic` - Simple pass/fail (fastest) - `:detailed` - Structured errors with paths (default) - `:verbose` - Comprehensive errors with context and suggestions ## Configuration & Options # Use predefined profiles for common scenarios strict_opts = ExJsonschema.Options.new(:strict) # API validation lenient_opts = ExJsonschema.Options.new(:lenient) # User forms perf_opts = ExJsonschema.Options.new(:performance) # High-volume # Enable validation options ExJsonschema.validate(validator, json, validate_formats: true) ExJsonschema.valid?(validator, json, stop_on_first_error: true) ## Draft Support & Meta-Validation Supports JSON Schema drafts 4, 6, 7, 2019-09, and 2020-12: ExJsonschema.compile_draft7(schema) # Draft-specific compilation ExJsonschema.compile_auto_draft(schema) # Auto-detect from $schema # Validate schema documents against meta-schemas ExJsonschema.meta_valid?(schema) # Quick check ExJsonschema.meta_validate(schema) # Detailed errors ## Error Handling & Analysis Rich error formatting and intelligent analysis: {:error, errors} = ExJsonschema.validate(validator, invalid_data) # Format for display ExJsonschema.format_errors(errors, :human, color: true) ExJsonschema.format_errors(errors, :json, pretty: true) ExJsonschema.format_errors(errors, :table, compact: true) # Analyze patterns and get recommendations analysis = ExJsonschema.analyze_errors(errors) analysis.total_errors # => 5 analysis.categories # => %{type_mismatch: 2, constraint_violation: 3} analysis.recommendations # => ["Review required fields...", ...] ## Comprehensive Documentation For detailed guides, examples, and advanced usage: - [HexDocs](https://hexdocs.pm/ex_jsonschema) - Complete API reference - [GitHub](https://github.com/hassox/ex_jsonschema) - Source code and examples - `docs/guides/` - In-depth usage guides and integration patterns Built on the blazing-fast Rust `jsonschema` crate for optimal performance. """ require Logger alias ExJsonschema.{ CompilationError, DraftDetector, ErrorAnalyzer, ErrorFormatter, MetaValidator, Native, Options, ValidationError } @typedoc """ A compiled JSON Schema validator optimized for repeated use. This is an opaque reference to a compiled schema stored in the Rust NIF. Compile once with `compile/1` or `compile/2`, then use multiple times with `validate/2`, `validate/3`, or `valid?/2`, `valid?/3`. ## Performance Note Compiled schemas are significantly faster than one-shot validation when validating multiple instances against the same schema. """ @type compiled_schema :: reference() @typedoc """ A JSON document represented as a string. Must be valid JSON syntax. Both the schema and instance documents are expected to be JSON strings that will be parsed by the Rust NIF. ## Examples "{\\"type\\": \\"string\\"}" "{\\"name\\": \\"John\\", \\"age\\": 30}" "[\\"item1\\", \\"item2\\", \\"item3\\"]" """ @type json_string :: String.t() @typedoc """ Result of validation operations. - `:ok` - Validation succeeded, the instance is valid - `{:error, [ValidationError.t()]}` - Validation failed with detailed error information The error list contains structured error information including: - Instance path (where the error occurred) - Schema path (which schema rule failed) - Descriptive error message - Additional context in verbose mode """ @type validation_result :: :ok | {:error, [ValidationError.t()]} @typedoc """ Result of basic validation operations. - `:ok` - Validation succeeded - `{:error, :validation_failed}` - Validation failed (no detailed errors) This is returned by validation with `output: :basic` for fastest performance when you only need to know if validation passed or failed. """ @type basic_validation_result :: :ok | {:error, :validation_failed} @doc """ Compiles a JSON Schema string into an optimized validator. ## Examples iex> schema = ~s({"type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile(schema) iex> is_reference(compiled) true iex> invalid_schema = ~s({"type": "invalid_type"}) iex> {:error, %ExJsonschema.CompilationError{type: :compilation_error}} = ExJsonschema.compile(invalid_schema) """ @spec compile(json_string()) :: {:ok, compiled_schema()} | {:error, CompilationError.t()} def compile(schema_json) when is_binary(schema_json) do compile(schema_json, []) end @doc """ Compiles a JSON Schema string with options into an optimized validator. ## Options Accepts either an `ExJsonschema.Options` struct or keyword list of options. When using `:auto` draft detection, the `$schema` property in the schema will be examined to determine the appropriate JSON Schema draft version. ## Examples # With Options struct iex> opts = ExJsonschema.Options.new(draft: :draft7, validate_formats: true) iex> schema = ~s({"type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile(schema, opts) iex> is_reference(compiled) true # With keyword list iex> schema = ~s({"type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile(schema, draft: :auto) iex> is_reference(compiled) true # Automatic draft detection iex> schema_with_draft = ~s({"$schema": "http://json-schema.org/draft-07/schema#", "type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile(schema_with_draft, draft: :auto) iex> is_reference(compiled) true """ @spec compile(json_string(), Options.t() | keyword()) :: {:ok, compiled_schema()} | {:error, CompilationError.t()} def compile(schema_json, %Options{} = options) when is_binary(schema_json) do Logger.debug("Starting schema compilation", %{ schema_size: byte_size(schema_json), draft: options.draft, output_format: options.output_format }) result = compile_with_options(schema_json, options) case result do {:ok, _compiled} -> Logger.info("Schema compilation successful", %{ schema_size: byte_size(schema_json), draft: options.draft }) {:error, error} -> Logger.error("Schema compilation failed", %{ schema_size: byte_size(schema_json), draft: options.draft, error: inspect(error) }) end result end def compile(schema_json, options) when is_binary(schema_json) and is_list(options) do Logger.debug("Converting keyword options to Options struct", %{ options: options, schema_size: byte_size(schema_json) }) case Options.validate(Options.new(options)) do {:ok, validated_options} -> Logger.debug("Options validation successful") compile_with_options(schema_json, validated_options) {:error, reason} -> Logger.warning("Options validation failed", %{ reason: reason, options: options }) {:error, CompilationError.from_options_error(reason)} end end @doc """ Compiles a JSON Schema, raising an exception on failure. ## Examples iex> schema = ~s({"type": "string"}) iex> compiled = ExJsonschema.compile!(schema) iex> is_reference(compiled) true """ @spec compile!(json_string()) :: compiled_schema() def compile!(schema_json) when is_binary(schema_json) do case compile(schema_json) do {:ok, compiled} -> compiled {:error, %CompilationError{} = error} -> raise ArgumentError, "Failed to compile schema: #{error}" end end @doc """ Validates JSON against a compiled schema. Returns `:ok` if valid, or `{:error, errors}` with detailed error information. ## Examples iex> schema = ~s({"type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile(schema) iex> ExJsonschema.validate(compiled, ~s("hello")) :ok iex> match?({:error, [%ExJsonschema.ValidationError{} | _]}, ExJsonschema.validate(compiled, ~s(123))) true """ @spec validate(compiled_schema(), json_string()) :: validation_result() def validate(compiled_schema, instance_json) when is_reference(compiled_schema) and is_binary(instance_json) do validate(compiled_schema, instance_json, []) end @doc """ Validates JSON against a compiled schema with output format and validation options control. ## Output Formats - `:basic` - Returns `:ok` or `{:error, :validation_failed}` (fastest) - `:detailed` - Returns `:ok` or `{:error, [ValidationError.t()]}` (default) - `:verbose` - Returns detailed errors with additional context, values, and suggestions ## Validation Options - `output: :basic | :detailed | :verbose` - Controls error output format (default: `:detailed`) **Note**: Validation behavior options like `validate_formats`, `stop_on_first_error`, etc. must be set during schema compilation, not at validation time. Use `ExJsonschema.compile/2` with appropriate options to control validation behavior. ## Examples # Basic format (fastest) iex> schema = ~s({"type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile(schema) iex> ExJsonschema.validate(compiled, ~s("hello"), output: :basic) :ok iex> ExJsonschema.validate(compiled, ~s(123), output: :basic) {:error, :validation_failed} # Detailed format (default) iex> schema = ~s({"type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile(schema) iex> ExJsonschema.validate(compiled, ~s(123), output: :detailed) {:error, [%ExJsonschema.ValidationError{}]} # Note: For format validation, set during compilation: # schema = ~s({"type": "string", "format": "email"}) # {:ok, compiled} = ExJsonschema.compile(schema, validate_formats: true) # ExJsonschema.validate(compiled, ~s("not-email")) # Now validates formats """ @spec validate(compiled_schema(), json_string(), keyword() | Options.t()) :: validation_result() | basic_validation_result() # Accept Options struct def validate(compiled_schema, instance_json, %Options{} = options) when is_reference(compiled_schema) and is_binary(instance_json) do Logger.debug("Starting validation", %{ instance_size: byte_size(instance_json), output_format: options.output_format, validate_formats: options.validate_formats }) result = validate_with_options(compiled_schema, instance_json, options) case result do :ok -> Logger.debug("Validation successful", %{ instance_size: byte_size(instance_json), output_format: options.output_format }) {:error, :validation_failed} -> Logger.debug("Basic validation failed", %{ instance_size: byte_size(instance_json) }) {:error, errors} when is_list(errors) -> Logger.debug("Validation failed with errors", %{ instance_size: byte_size(instance_json), error_count: length(errors), output_format: options.output_format }) {:error, error} -> Logger.warning("Validation failed with unexpected error", %{ instance_size: byte_size(instance_json), error: inspect(error) }) end result end # Accept keyword list def validate(compiled_schema, instance_json, opts) when is_reference(compiled_schema) and is_binary(instance_json) and is_list(opts) do Logger.debug("Converting validation options", %{ instance_size: byte_size(instance_json), options: opts }) validated_options = validate_and_normalize_options(opts) validate_with_options(compiled_schema, instance_json, validated_options) end @doc """ Validates JSON against a compiled schema, raising an exception on validation failure. ## Examples iex> schema = ~s({"type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile(schema) iex> ExJsonschema.validate!(compiled, ~s("hello")) :ok """ @spec validate!(compiled_schema(), json_string()) :: :ok def validate!(compiled_schema, instance_json) do case validate(compiled_schema, instance_json) do :ok -> :ok {:error, errors} -> raise ExJsonschema.ValidationError.Exception, errors: errors end end @doc """ Checks if JSON is valid against a compiled schema without returning error details. This is faster than `validate/2` when you only need to know if the JSON is valid. ## Examples iex> schema = ~s({"type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile(schema) iex> ExJsonschema.valid?(compiled, ~s("hello")) true iex> ExJsonschema.valid?(compiled, ~s(123)) false """ @spec valid?(compiled_schema(), json_string()) :: boolean() def valid?(compiled_schema, instance_json) when is_reference(compiled_schema) and is_binary(instance_json) do Native.valid?(compiled_schema, instance_json) end @doc """ Checks if JSON is valid against a compiled schema with validation options. This is faster than `validate/3` when you only need to know if the JSON is valid and supports validation options like format validation. ## Validation Options - `validate_formats: boolean()` - Enable format validation (default: `false`) - `ignore_unknown_formats: boolean()` - Ignore unknown format assertions (default: `true`) - `stop_on_first_error: boolean()` - Stop validation on first error (default: `false`) - `collect_annotations: boolean()` - Collect annotations during validation (default: `true`) ## Examples iex> schema = ~s({"type": "string", "format": "email"}) iex> {:ok, compiled} = ExJsonschema.compile(schema) iex> ExJsonschema.valid?(compiled, ~s("test@example.com"), validate_formats: true) true iex> ExJsonschema.valid?(compiled, ~s("not-email"), validate_formats: true) false """ @spec valid?(compiled_schema(), json_string(), keyword() | Options.t()) :: boolean() # Accept Options struct def valid?(compiled_schema, instance_json, %Options{} = options) when is_reference(compiled_schema) and is_binary(instance_json) do valid_with_options(compiled_schema, instance_json, options) end # Accept keyword list def valid?(compiled_schema, instance_json, opts) when is_reference(compiled_schema) and is_binary(instance_json) and is_list(opts) do validated_options = validate_and_normalize_options(opts) valid_with_options(compiled_schema, instance_json, validated_options) end @doc """ One-shot validation: compiles schema and validates instance in a single call. This is convenient for one-time validations but less efficient for repeated validations of the same schema. ## Examples iex> schema = ~s({"type": "string"}) iex> ExJsonschema.validate_once(schema, ~s("hello")) :ok iex> match?({:error, [%ExJsonschema.ValidationError{} | _]}, ExJsonschema.validate_once(schema, ~s(123))) true """ @spec validate_once(json_string(), json_string()) :: validation_result() | {:error, CompilationError.t()} def validate_once(schema_json, instance_json) do with {:ok, compiled} <- compile(schema_json) do validate(compiled, instance_json) end end @doc """ Compiles a JSON Schema using Draft 4 specific optimizations. This provides a direct shortcut to compile schemas specifically for Draft 4, which can be faster than using the generic `compile/2` with draft options. ## Examples iex> schema = ~s({"type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile_draft4(schema) iex> is_reference(compiled) true # With additional options iex> {:ok, compiled} = ExJsonschema.compile_draft4(schema, validate_formats: true) iex> is_reference(compiled) true """ @spec compile_draft4(json_string(), keyword()) :: {:ok, compiled_schema()} | {:error, CompilationError.t()} def compile_draft4(schema_json, options \\ []) when is_binary(schema_json) do options_with_draft = Keyword.put(options, :draft, :draft4) compile(schema_json, options_with_draft) end @doc """ Compiles a JSON Schema using Draft 6 specific optimizations. ## Examples iex> schema = ~s({"type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile_draft6(schema) iex> is_reference(compiled) true """ @spec compile_draft6(json_string(), keyword()) :: {:ok, compiled_schema()} | {:error, CompilationError.t()} def compile_draft6(schema_json, options \\ []) when is_binary(schema_json) do options_with_draft = Keyword.put(options, :draft, :draft6) compile(schema_json, options_with_draft) end @doc """ Compiles a JSON Schema using Draft 7 specific optimizations. Draft 7 is widely used and includes support for conditional schemas with `if`, `then`, `else`. ## Examples iex> schema = ~s({"type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile_draft7(schema) iex> is_reference(compiled) true """ @spec compile_draft7(json_string(), keyword()) :: {:ok, compiled_schema()} | {:error, CompilationError.t()} def compile_draft7(schema_json, options \\ []) when is_binary(schema_json) do options_with_draft = Keyword.put(options, :draft, :draft7) compile(schema_json, options_with_draft) end @doc """ Compiles a JSON Schema using Draft 2019-09 specific optimizations. ## Examples iex> schema = ~s({"type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile_draft201909(schema) iex> is_reference(compiled) true """ @spec compile_draft201909(json_string(), keyword()) :: {:ok, compiled_schema()} | {:error, CompilationError.t()} def compile_draft201909(schema_json, options \\ []) when is_binary(schema_json) do options_with_draft = Keyword.put(options, :draft, :draft201909) compile(schema_json, options_with_draft) end @doc """ Compiles a JSON Schema using Draft 2020-12 specific optimizations. Draft 2020-12 is the latest specification with the most comprehensive feature set. ## Examples iex> schema = ~s({"type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile_draft202012(schema) iex> is_reference(compiled) true """ @spec compile_draft202012(json_string(), keyword()) :: {:ok, compiled_schema()} | {:error, CompilationError.t()} def compile_draft202012(schema_json, options \\ []) when is_binary(schema_json) do options_with_draft = Keyword.put(options, :draft, :draft202012) compile(schema_json, options_with_draft) end @doc """ Detects the JSON Schema draft version from a schema document. This function examines the `$schema` property to determine which JSON Schema draft version the schema is written for. If no `$schema` is present or the URL is unrecognized, it returns the default draft (2020-12). ## Examples # Schema with explicit draft iex> schema = ~s({"$schema": "http://json-schema.org/draft-07/schema#", "type": "string"}) iex> ExJsonschema.detect_draft(schema) {:ok, :draft7} # Schema without $schema (uses default) iex> schema = ~s({"type": "number"}) iex> ExJsonschema.detect_draft(schema) {:ok, :draft202012} # Map input iex> schema = %{"$schema" => "https://json-schema.org/draft/2020-12/schema", "type" => "object"} iex> ExJsonschema.detect_draft(schema) {:ok, :draft202012} """ @spec detect_draft(json_string() | map()) :: {:ok, Options.draft()} | {:error, String.t()} def detect_draft(schema) do DraftDetector.detect_draft(schema) end @doc """ Returns all supported JSON Schema draft versions. ## Examples iex> drafts = ExJsonschema.supported_drafts() iex> :draft7 in drafts true iex> :draft202012 in drafts true """ @spec supported_drafts() :: [Options.draft()] def supported_drafts do DraftDetector.supported_drafts() end @doc """ Compiles a schema using the automatically detected draft version. This is a convenience function that combines `detect_draft/1` and `compile/2` to automatically select the most appropriate draft for the schema. ## Examples # Schema with explicit $schema iex> schema = ~s({"$schema": "http://json-schema.org/draft-07/schema#", "type": "string"}) iex> {:ok, compiled} = ExJsonschema.compile_auto_draft(schema) iex> is_reference(compiled) true # Schema without $schema (defaults to latest) iex> schema = ~s({"type": "number"}) iex> {:ok, compiled} = ExJsonschema.compile_auto_draft(schema) iex> is_reference(compiled) true """ @spec compile_auto_draft(json_string(), keyword()) :: {:ok, compiled_schema()} | {:error, CompilationError.t()} def compile_auto_draft(schema_json, options \\ []) when is_binary(schema_json) do options_with_auto = Keyword.put(options, :draft, :auto) compile(schema_json, options_with_auto) end @doc """ Formats validation errors for display using the specified format. This is a convenience function that delegates to `ExJsonschema.ErrorFormatter.format/3`. ## Supported Formats - `:human` - Human-readable text format with colors and suggestions - `:json` - Structured JSON format for programmatic use - `:table` - Tabular format for easy scanning of multiple errors - `:markdown` - Markdown format for documentation and web display - `:llm` - LLM-optimized format for AI assistant consumption ## Examples # Format errors for human consumption ExJsonschema.format_errors(errors, :human) # Format as pretty-printed JSON ExJsonschema.format_errors(errors, :json, pretty: true) # Format as compact table ExJsonschema.format_errors(errors, :table, compact: true) """ @spec format_errors( [ValidationError.t()], ErrorFormatter.format(), ErrorFormatter.format_options() ) :: String.t() def format_errors(errors, format, options \\ []) do Logger.debug("Formatting validation errors", %{ error_count: length(errors), format: format, options: options }) result = ErrorFormatter.format(errors, format, options) Logger.info("Error formatting complete", %{ error_count: length(errors), format: format, output_size: byte_size(result) }) result end @doc """ Analyzes validation errors to provide insights, categorization, and recommendations. Returns a comprehensive analysis including error categories, severity levels, detected patterns, and actionable recommendations for fixing the issues. ## Examples {:error, errors} = ExJsonschema.validate(validator, invalid_json) analysis = ExJsonschema.analyze_errors(errors) analysis.total_errors #=> 3 analysis.categories #=> %{type_mismatch: 1, constraint_violation: 2} analysis.recommendations #=> ["Review required fields - ensure all mandatory properties are included", ...] # Get human-readable summary ExJsonschema.analyze_errors(errors, :summary) #=> "3 validation errors detected\\n\\nCategories: 1 type mismatches, 2 constraint violations..." """ @spec analyze_errors([ValidationError.t()]) :: ErrorAnalyzer.error_analysis() @spec analyze_errors([ValidationError.t()], :summary) :: String.t() def analyze_errors(errors, format \\ :analysis) def analyze_errors(errors, :analysis) when is_list(errors) do ErrorAnalyzer.analyze(errors) end def analyze_errors(errors, :summary) when is_list(errors) do ErrorAnalyzer.summarize(errors) end # Meta-validation functions @doc """ Checks if a JSON Schema document is valid against its meta-schema. This function validates that the provided schema document itself follows the correct JSON Schema specification for its draft version. ## Examples iex> schema = ~s({"type": "string", "minLength": 5}) iex> ExJsonschema.meta_valid?(schema) true iex> invalid_schema = ~s({"type": "invalid_type"}) iex> ExJsonschema.meta_valid?(invalid_schema) false ## Parameters - `schema_json` - JSON string containing the schema to validate ## Returns - `true` if the schema is valid against its meta-schema - `false` if the schema is invalid - Raises `ArgumentError` if JSON is malformed """ @spec meta_valid?(binary()) :: boolean() defdelegate meta_valid?(schema_json), to: MetaValidator, as: :valid? @doc """ Validates a JSON Schema document against its meta-schema. Returns detailed validation errors compatible with standard validation error formatting and analysis tools. ## Examples iex> schema = ~s({"type": "string", "minLength": 5}) iex> ExJsonschema.meta_validate(schema) :ok iex> invalid_schema = ~s({"type": "invalid_type"}) iex> {:error, errors} = ExJsonschema.meta_validate(invalid_schema) iex> ExJsonschema.format_errors(errors, :human) ## Parameters - `schema_json` - JSON string containing the schema to validate ## Returns - `:ok` if the schema is valid - `{:error, [ExJsonschema.ValidationError.t()]}` if validation fails - `{:error, reason}` if JSON parsing fails """ @spec meta_validate(binary()) :: :ok | {:error, [ValidationError.t()]} | {:error, binary()} defdelegate meta_validate(schema_json), to: MetaValidator, as: :validate @doc """ Validates a JSON Schema document against its meta-schema, raising on error. Like `meta_validate/1` but raises `ExJsonschema.ValidationError` if validation fails. ## Examples iex> schema = ~s({"type": "string", "minLength": 5}) iex> ExJsonschema.meta_validate!(schema) :ok ## Parameters - `schema_json` - JSON string containing the schema to validate ## Returns - `:ok` if the schema is valid - Raises `ExJsonschema.ValidationError` if validation fails - Raises `ArgumentError` if JSON is malformed """ @spec meta_validate!(binary()) :: :ok defdelegate meta_validate!(schema_json), to: MetaValidator, as: :validate! @doc """ Extracts all external `$ref` URIs from a JSON Schema string. Walks the schema tree and collects every `$ref` value that is not a local fragment reference (i.e., does not start with `#`). Returns a deduplicated list of URI strings. ## Examples iex> schema = ~s({"$ref": "https://example.com/person.json"}) iex> ExJsonschema.extract_refs(schema) {:ok, ["https://example.com/person.json"]} iex> schema = ~s({"$ref": "#/definitions/name"}) iex> ExJsonschema.extract_refs(schema) {:ok, []} """ @spec extract_refs(json_string()) :: {:ok, [String.t()]} | {:error, String.t()} def extract_refs(schema_json) when is_binary(schema_json) do case Jason.decode(schema_json) do {:ok, decoded} -> refs = decoded |> collect_refs() |> MapSet.to_list() {:ok, refs} {:error, %Jason.DecodeError{} = e} -> {:error, "Invalid JSON: #{Exception.message(e)}"} end end # Private helper functions for validation options defp validate_and_normalize_options(opts) do # Only accept output format at validation time valid_options = [:output] deprecated_options = [ :validate_formats, :ignore_unknown_formats, :stop_on_first_error, :collect_annotations ] # Check for invalid options invalid_opts = Keyword.keys(opts) -- (valid_options ++ deprecated_options) unless Enum.empty?(invalid_opts) do raise ArgumentError, "Invalid validation option(s): #{inspect(invalid_opts)}. " <> "Valid option: :output" end # Warn about deprecated options for opt <- deprecated_options do if Keyword.has_key?(opts, opt) do Logger.warning( "Option #{inspect(opt)} ignored at validation time. Set during compilation with ExJsonschema.compile/2 instead." ) end end # Validate output format if Keyword.has_key?(opts, :output) do output = Keyword.get(opts, :output) unless output in [:basic, :detailed, :verbose] do raise ArgumentError, "Invalid output format: #{inspect(output)}. Must be one of: :basic, :detailed, :verbose" end end # Create minimal Options struct with only output format %Options{output_format: Keyword.get(opts, :output, :detailed)} end defp validate_with_options( compiled_schema, instance_json, %Options{output_format: output_format} = options ) do case output_format do :basic -> validate_basic_with_options(compiled_schema, instance_json, options) :detailed -> validate_detailed_with_options(compiled_schema, instance_json, options) :verbose -> validate_verbose_with_options(compiled_schema, instance_json, options) _ -> raise ArgumentError, "Invalid output format: #{inspect(output_format)}. Must be one of: :basic, :detailed, :verbose" end end defp valid_with_options(compiled_schema, instance_json, %Options{} = options) do # For now, use basic validation with options # In the future, this could be optimized to use a dedicated native function case validate_with_options(compiled_schema, instance_json, %{options | output_format: :basic}) do :ok -> true {:error, _} -> false end end # Private validation functions for different output formats defp validate_basic(compiled_schema, instance_json) do if Native.valid?(compiled_schema, instance_json) do :ok else {:error, :validation_failed} end end defp validate_detailed(compiled_schema, instance_json) do case Native.validate_detailed(compiled_schema, instance_json) do :ok -> :ok {:error, error_list} when is_list(error_list) -> errors = Enum.map(error_list, &ValidationError.from_detailed_map/1) {:error, errors} {:error, _reason} -> {:error, [:validation_error]} end end defp validate_verbose(compiled_schema, instance_json, _opts) do case Native.validate_verbose(compiled_schema, instance_json) do :ok -> :ok {:error, error_list} when is_list(error_list) -> errors = Enum.map(error_list, &ValidationError.from_map/1) {:error, errors} {:error, _reason} -> {:error, [:validation_error]} end end # Validation functions with output format selection # Validation behavior options (validate_formats, stop_on_first_error, etc.) are baked # into the compiled schema during compilation, not passed at validation time. defp validate_basic_with_options(compiled_schema, instance_json, %Options{} = _options) do # Output format is handled at calling level - validation behavior is baked into compiled schema validate_basic(compiled_schema, instance_json) end defp validate_detailed_with_options(compiled_schema, instance_json, %Options{} = _options) do # Output format is handled at calling level - validation behavior is baked into compiled schema validate_detailed(compiled_schema, instance_json) end defp validate_verbose_with_options(compiled_schema, instance_json, %Options{} = _options) do # Output format is handled at calling level - validation behavior is baked into compiled schema validate_verbose(compiled_schema, instance_json, []) end # Private functions defp compile_with_options(schema_json, %Options{draft: :auto} = options) do Logger.debug("Auto-detecting JSON Schema draft") # Auto-detect draft from schema and update options case DraftDetector.detect_draft(schema_json) do {:ok, detected_draft} -> Logger.debug("Draft auto-detection successful", %{detected_draft: detected_draft}) updated_options = %{options | draft: detected_draft} compile_with_native_options(schema_json, updated_options) {:error, reason} -> Logger.warning("Draft auto-detection failed", %{reason: reason}) {:error, CompilationError.from_detection_error(reason)} end end defp compile_with_options(schema_json, %Options{} = options) do compile_with_native_options(schema_json, options) end defp compile_with_native_options(schema_json, %Options{draft: draft} = options) do Logger.debug("Compiling with native options", %{draft: draft}) # Check cache first using schema ID case extract_schema_id(schema_json) do {:ok, schema_id} -> cache_module = get_cache_module() case cache_module.get(schema_id) do {:ok, cached_compiled} -> Logger.debug("Cache hit for schema compilation", %{ schema_id: schema_id, cache: cache_module }) {:ok, cached_compiled} {:error, :not_found} -> Logger.debug("Cache miss, proceeding with compilation", %{ schema_id: schema_id, cache: cache_module }) compile_and_cache(schema_json, schema_id, options) {:error, :no_test_cache_configured} -> Logger.debug("No test cache configured, proceeding without caching") compile_without_cache(schema_json, options) end {:error, :no_id} -> Logger.debug("No schema ID found, proceeding without caching") compile_without_cache(schema_json, options) end end defp compile_and_cache(schema_json, schema_id, options) do case compile_without_cache(schema_json, options) do {:ok, compiled} -> # Store in cache for future use cache_module = get_cache_module() cache_module.put(schema_id, compiled) {:ok, compiled} error -> error end end defp compile_without_cache(schema_json, %Options{} = options) do native_options = ExJsonschema.Native.ValidationOptions.from_options(options) case validate_compilation_options(schema_json, options) do :ok -> compile_with_resolution(schema_json, native_options, options) {:error, reason} -> {:error, CompilationError.from_validation_error(reason)} end end # Route to the right NIF based on ref_resolver / external_schemas config defp compile_with_resolution(schema_json, native_options, %Options{} = options) do case {options.ref_resolver, options.external_schemas} do {nil, mode} when mode in [:ignore, :http] -> # Simple mode — handled entirely by the Rust retriever compile_via_nif(schema_json, native_options) {nil, %{} = map} -> # Pre-resolved map supplied directly by the caller compile_with_resolved_map(schema_json, native_options, map) {resolver_module, _} -> # Behaviour-based resolver — extract refs, resolve, pass map to NIF case resolve_all_refs(schema_json, resolver_module) do {:ok, resolved} -> compile_with_resolved_map(schema_json, native_options, resolved) {:error, reason} -> {:error, CompilationError.from_ref_resolution_error(reason)} end end end defp compile_via_nif(schema_json, native_options) do case Native.compile_schema_with_options(schema_json, native_options) do {:ok, compiled} -> {:ok, compiled} {:error, error_map} -> {:error, CompilationError.from_map(error_map)} end end defp compile_with_resolved_map(schema_json, native_options, resolved_map) do case Native.compile_schema_with_resolved_schemas(schema_json, native_options, resolved_map) do {:ok, compiled} -> {:ok, compiled} {:error, error_map} -> {:error, CompilationError.from_map(error_map)} end end # Validates that compilation options are consistent with the schema defp validate_compilation_options(schema_json, %Options{draft: draft} = _options) do # When draft is not :auto, validate it matches schema if schema has $schema case DraftDetector.detect_draft(schema_json) do {:ok, detected_draft} -> if draft != :auto and draft != detected_draft do case DraftDetector.schema_has_draft?(schema_json) do true -> {:error, "Schema specifies #{detected_draft} but options specify #{draft}"} false -> # No $schema in document, options draft is fine :ok end else :ok end {:error, reason} -> {:error, "Draft detection failed: #{reason}"} end end # Extract schema ID for caching purposes # Looks for $id first (preferred), then falls back to $schema defp get_cache_module do Application.get_env(:ex_jsonschema, :cache, ExJsonschema.Cache.Noop) end defp extract_schema_id(schema_json) when is_binary(schema_json) do case Jason.decode(schema_json) do {:ok, schema_map} when is_map(schema_map) -> schema_id = Map.get(schema_map, "$id") || Map.get(schema_map, "$schema") case schema_id do id when is_binary(id) and byte_size(id) > 0 -> {:ok, id} _ -> {:error, :no_id} end {:ok, _} -> # Boolean schemas or other non-map schemas don't have IDs {:error, :no_id} {:error, _} -> # Malformed JSON - no ID can be extracted {:error, :no_id} end rescue _ -> {:error, :no_id} end # -- Ref extraction & resolution helpers -- defp collect_refs(value, acc \\ MapSet.new()) defp collect_refs(%{"$ref" => ref} = map, acc) when is_binary(ref) do acc = if String.starts_with?(ref, "#"), do: acc, else: MapSet.put(acc, ref) # Continue walking — there may be siblings (e.g., in older drafts) Enum.reduce(map, acc, fn {"$ref", _}, inner_acc -> inner_acc {_k, v}, inner_acc -> collect_refs(v, inner_acc) end) end defp collect_refs(%{} = map, acc) do Enum.reduce(map, acc, fn {_k, v}, inner_acc -> collect_refs(v, inner_acc) end) end defp collect_refs(list, acc) when is_list(list) do Enum.reduce(list, acc, fn item, inner_acc -> collect_refs(item, inner_acc) end) end defp collect_refs(_scalar, acc), do: acc defp resolve_all_refs(schema_json, resolver_module) do resolve_refs_loop(schema_json, resolver_module, %{}, MapSet.new()) end defp resolve_refs_loop(schema_json, resolver, resolved, seen) do with {:ok, refs} <- extract_refs(schema_json) do new_refs = Enum.reject(refs, &MapSet.member?(seen, &1)) if new_refs == [] do {:ok, resolved} else case resolver.resolve(new_refs) do {:ok, newly_resolved} -> combined = Map.merge(resolved, newly_resolved) new_seen = MapSet.union(seen, MapSet.new(new_refs)) # Recursively resolve refs found inside the newly-resolved schemas Enum.reduce_while(newly_resolved, {:ok, combined, new_seen}, fn {_url, sub_json}, {:ok, acc, s} -> case resolve_refs_loop(sub_json, resolver, acc, s) do {:ok, new_acc} -> {:cont, {:ok, new_acc, s}} {:error, _} = err -> {:halt, err} end end) |> case do {:ok, final, _seen} -> {:ok, final} {:error, _} = err -> err end {:error, reason} -> {:error, reason} end end end end end