diff --git a/.JuliaFormatter.toml b/.JuliaFormatter.toml new file mode 100644 index 0000000..a700a07 --- /dev/null +++ b/.JuliaFormatter.toml @@ -0,0 +1,8 @@ +# Configuration file for JuliaFormatter.jl +# For more information, see: https://domluna.github.io/JuliaFormatter.jl/stable/config/ + +always_for_in = true +always_use_return = true +margin = 80 +remove_extra_newlines = true +short_to_long_function_def = true diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml deleted file mode 100644 index 8aa14bd..0000000 --- a/.github/workflows/CI.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: CI - -on: - push: - branches: [master] - tags: ["*"] - pull_request: - release: - -jobs: - test: - name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - version: - - '1' # automatically expands to the latest stable 1.x release of Julia - - 'min' - - 'pre' - os: - - ubuntu-latest - - windows-latest - arch: - - x64 - include: - - os: macOS-latest - arch: aarch64 - version: 1 - - os: ubuntu-latest - arch: x86 - version: 1 - steps: - - uses: actions/checkout@v6 - - uses: julia-actions/setup-julia@v2 - with: - version: ${{ matrix.version }} - arch: ${{ matrix.arch }} - - uses: julia-actions/cache@v2 - - uses: julia-actions/julia-buildpkg@v1 - - uses: julia-actions/julia-runtest@v1 - - uses: julia-actions/julia-processcoverage@v1 - - uses: codecov/codecov-action@v5 - with: - files: lcov.info - token: ${{ secrets.CODECOV_TOKEN }} - docs: - name: Documentation - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v6 - - uses: julia-actions/setup-julia@v2 - with: - version: '1' - - uses: julia-actions/cache@v2 - - uses: julia-actions/julia-buildpkg@v1 - - uses: julia-actions/julia-docdeploy@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DOCUMENTER_KEY: ${{ secrets.DOCUMENTER_KEY }} diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml deleted file mode 100644 index c12d063..0000000 --- a/.github/workflows/CompatHelper.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: CompatHelper -on: - schedule: - - cron: 0 0 * * * - workflow_dispatch: -permissions: - contents: write - pull-requests: write -jobs: - CompatHelper: - runs-on: ubuntu-latest - steps: - - name: Check if Julia is already available in the PATH - id: julia_in_path - run: which julia - continue-on-error: true - - name: Install Julia, but only if it is not already available in the PATH - uses: julia-actions/setup-julia@v2 - with: - version: '1' - # arch: ${{ runner.arch }} - if: steps.julia_in_path.outcome != 'success' - - name: "Add the General registry via Git" - run: | - import Pkg - ENV["JULIA_PKG_SERVER"] = "" - Pkg.Registry.add("General") - shell: julia --color=yes {0} - - name: "Install CompatHelper" - run: | - import Pkg - name = "CompatHelper" - uuid = "aa819f21-2bde-4658-8897-bab36330d9b7" - version = "3" - Pkg.add(; name, uuid, version) - shell: julia --color=yes {0} - - name: "Run CompatHelper" - run: | - import CompatHelper - CompatHelper.main() - shell: julia --color=yes {0} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMPATHELPER_PRIV: ${{ secrets.DOCUMENTER_KEY }} - # COMPATHELPER_PRIV: ${{ secrets.COMPATHELPER_PRIV }} diff --git a/.github/workflows/Runic.yml b/.github/workflows/Runic.yml deleted file mode 100644 index d7e3f39..0000000 --- a/.github/workflows/Runic.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Runic formatting -on: - push: - branches: - - 'master' - - 'release-' - tags: - - '*' - pull_request: -jobs: - runic: - name: Runic - runs-on: ubuntu-latest - # Permissions needed for reviewdog/action-suggester to post comments - permissions: - contents: read - checks: write - issues: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - # - uses: julia-actions/setup-julia@v2 - # with: - # version: '1' - # - uses: julia-actions/cache@v2 - - uses: fredrikekre/runic-action@v1 - with: - version: '1' - format_files: true - # Fail on next step instead - continue-on-error: ${{ github.event_name == 'pull_request' }} - - uses: reviewdog/action-suggester@v1 - if: github.event_name == 'pull_request' - with: - tool_name: Runic - fail_level: warning diff --git a/.github/workflows/TagBot.yml b/.github/workflows/TagBot.yml index ae8c9c1..778c06f 100644 --- a/.github/workflows/TagBot.yml +++ b/.github/workflows/TagBot.yml @@ -11,5 +11,4 @@ jobs: steps: - uses: JuliaRegistries/TagBot@v1 with: - ssh: ${{ secrets.DOCUMENTER_KEY }} - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2f7d46b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI +on: + push: + branches: [master] + pull_request: + types: [opened, synchronize, reopened] +# needed to allow julia-actions/cache to delete old caches that it has created +permissions: + actions: write + contents: read +jobs: + test: + name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + version: ['1.10', '1'] # Test against LTS and current minor release + os: [ubuntu-latest, macOS-latest, windows-latest] + arch: [x64] + # Upstream bug: https://github.com/JuliaIO/JSON.jl/issues/386 + # include: + # Also test against 32-bit Linux on LTS. + # - version: '1.10' + # os: ubuntu-latest + # arch: x86 + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v2 + with: + version: ${{ matrix.version }} + arch: ${{ matrix.arch }} + - uses: julia-actions/cache@v2 + - uses: julia-actions/julia-buildpkg@v1 + - uses: julia-actions/julia-runtest@v1 + - uses: julia-actions/julia-processcoverage@v1 + - uses: codecov/codecov-action@v4 + with: + file: lcov.info diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml new file mode 100644 index 0000000..1e3e618 --- /dev/null +++ b/.github/workflows/format-check.yml @@ -0,0 +1,31 @@ +name: format-check +on: + push: + branches: + - master + - release-* + pull_request: + types: [opened, synchronize, reopened] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: julia-actions/setup-julia@latest + with: + version: '1' + - uses: actions/checkout@v1 + - name: Format check + shell: julia --color=yes {0} + run: | + using Pkg + # If you update the version, also update the style guide docs. + Pkg.add(PackageSpec(name="JuliaFormatter")) + using JuliaFormatter + format(".", verbose=true) + out = String(read(Cmd(`git diff`))) + if isempty(out) + exit(0) + end + @error "Some files have not been formatted !!!" + write(stdout, out) + exit(1) diff --git a/.gitignore b/.gitignore index fbcb27a..3f02ca7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,3 @@ *.jl.*.cov *.jl.mem Manifest.toml -Manifest-*.toml -docs/build/ -docs/Manifest.toml -docs/Manifest-*.toml -test/Manifest.toml -test/Manifest-*.toml diff --git a/LICENSE.md b/LICENSE.md index 177b487..8a9e1c8 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ The JSONSchema.jl package is licensed under the MIT "Expat" License: -> Copyright (c) 2018-2026: fredo, quinnj. +> Copyright (c) 2018: fredo. > > Permission is hereby granted, free of charge, to any person obtaining a copy > of this software and associated documentation files (the "Software"), to deal @@ -19,4 +19,4 @@ The JSONSchema.jl package is licensed under the MIT "Expat" License: > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE > SOFTWARE. - +> diff --git a/Project.toml b/Project.toml index 20740be..63b7b46 100644 --- a/Project.toml +++ b/Project.toml @@ -1,16 +1,20 @@ name = "JSONSchema" uuid = "7d188eb4-7ad8-530c-ae41-71a32a6d4692" -version = "2.0.0" +version = "1.5.0" [deps] Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -StructUtils = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" +[weakdeps] +JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" + +[extensions] +JSONSchemaJSON3Ext = "JSON3" + [compat] -Downloads = "1" -JSON = "1" -StructUtils = "2" +JSON = "0.21, 1" +JSON3 = "1" URIs = "1" -julia = "1.10" +julia = "1.9" diff --git a/README.md b/README.md index e62b42b..8e118f7 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,82 @@ # JSONSchema.jl -[![CI](https://github.com/JuliaIO/JSONSchema.jl/actions/workflows/CI.yml/badge.svg?branch=master)](https://github.com/JuliaIO/JSONSchema.jl/actions?query=workflow%3ACI) -[![codecov](https://codecov.io/gh/JuliaIO/JSONSchema.jl/branch/master/graph/badge.svg)](https://codecov.io/gh/JuliaIO/JSONSchema.jl) -[![Docs](https://img.shields.io/badge/docs-stable-blue.svg)](https://juliaio.github.io/JSONSchema.jl/stable) +[![Build Status](https://github.com/fredo-dedup/JSONSchema.jl/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/fredo-dedup/JSONSchema.jl/actions?query=workflow%3ACI) +[![codecov](https://codecov.io/gh/fredo-dedup/JSONSchema.jl/branch/master/graph/badge.svg)](https://codecov.io/gh/fredo-dedup/JSONSchema.jl) ## Overview -JSONSchema.jl generates JSON Schema (draft-07) from Julia types and validates -instances against those schemas. It also supports validating data against -hand-written JSON Schema objects. Field-level validation rules are provided via -`StructUtils` tags. +[JSONSchema.jl](https://github.com/fredo-dedup/JSONSchema.jl) is a JSON +validation package for the [Julia](https://julialang.org/) programming language. +Given a [validation schema](http://json-schema.org/specification.html), this +package can verify if a JSON instance meets all the assertions that define a +valid document. -> **Upgrading from v1.x?** See the [v2.0 Migration Guide](https://juliaio.github.io/JSONSchema.jl/stable/migration/) for breaking changes and upgrade instructions. - -The test harness is wired to the +This package has been tested with the [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite) -for draft4, draft6, and draft7. +for draft v4 and v6. -## Installation +## API +Create a `Schema` object by passing a string: ```julia -using Pkg -Pkg.add("JSONSchema") +julia> my_schema = Schema("""{ + "properties": { + "foo": {}, + "bar": {} + }, + "required": ["foo"] + }""") +``` +passing a dictionary with the same structure as a schema: +```julia +julia> my_schema = Schema( + Dict( + "properties" => Dict( + "foo" => Dict(), + "bar" => Dict() + ), + "required" => ["foo"] + ) + ) +``` +or by passing a parsed JSON file containing the schema: +```julia +julia> my_schema = Schema(JSON.parsefile(filename)) ``` -## Usage - -### Generate a schema from a Julia type +Check the validity of a parsed JSON instance by calling `validate` with the JSON +instance `x` to be tested and the `schema`. +If the validation succeeds, `validate` returns `nothing`: ```julia -using JSONSchema, StructUtils +julia> document = """{"foo": true}"""; -@defaults struct User - id::Int = 0 &(json=(minimum=1,),) - name::String = "" &(json=(minLength=1,),) - email::String = "" &(json=(format="email",),) -end +julia> data_pass = JSON.parse(document) +Dict{String,Bool} with 1 entry: + "foo" => true -schema = JSONSchema.schema(User) -user = User(1, "Alice", "alice@example.com") +julia> validate(my_schema, data_pass) -isvalid(schema, user) # true ``` -### Validate JSON data against a schema object - +If the validation fails, a struct is returned that, when printed, explains the +reason for the failure: ```julia -using JSON, JSONSchema - -schema = JSONSchema.Schema(JSON.parse(""" -{ - "type": "object", - "properties": {"foo": {"type": "integer"}}, - "required": ["foo"] -} -""")) - -data = JSON.parse("""{"foo": 1}""") -isvalid(schema, data) # true +julia> data_fail = Dict("bar" => 12.5) +Dict{String,Float64} with 1 entry: + "bar" => 12.5 + +julia> validate(my_schema, data_fail) +Validation failed: +path: top-level +instance: Dict("bar"=>12.5) +schema key: required +schema value: ["foo"] ``` -## Features - -- **Schema Generation**: Automatically generate JSON Schema from Julia struct definitions -- **Type-Safe Validation**: Validate Julia instances against generated schemas -- **StructUtils Integration**: Use field tags for validation rules (min/max, patterns, formats, etc.) -- **Composition Support**: `oneOf`, `anyOf`, `allOf`, `not` combinators -- **Reference Support**: `$ref` with `definitions` for complex/recursive types -- **Format Validation**: Built-in validators for `email`, `uri`, `uuid`, `date-time` - -## Documentation +As a short-hand for `validate(schema, x) === nothing`, use +`Base.isvalid(schema, x)` -See the [documentation](https://juliaio.github.io/JSONSchema.jl/stable) for: -- Complete API reference -- Validation rules and field tags -- Type mapping reference -- Advanced usage with `$ref` and composition +Note that if `x` is a `String` in JSON format, you must use `JSON.parse(x)` +before passing to `validate`, that is, JSONSchema operates on the parsed +representation, not on the underlying `String` representation of the JSON data. diff --git a/docs/Project.toml b/docs/Project.toml deleted file mode 100644 index 91ec7f8..0000000 --- a/docs/Project.toml +++ /dev/null @@ -1,6 +0,0 @@ -[deps] -Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" -JSONSchema = "7d188eb4-7ad8-530c-ae41-71a32a6d4692" - -[compat] -Documenter = "1" diff --git a/docs/make.jl b/docs/make.jl deleted file mode 100644 index 6f98211..0000000 --- a/docs/make.jl +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) 2018-2026: fredo-dedup, quinnj, and contributors -# -# Use of this source code is governed by an MIT-style license that can be found -# in the LICENSE.md file or at https://opensource.org/licenses/MIT. - -using Documenter, JSONSchema - -makedocs( - modules = [JSONSchema], - sitename = "JSONSchema.jl", - pages = [ - "Home" => "index.md", - "JSON Schema" => "schema.md", - "API Reference" => "reference.md", - "v2.0 Migration Guide" => "migration.md", - ], -) - -deploydocs(repo = "github.com/JuliaIO/JSONSchema.jl.git", push_preview = true) diff --git a/docs/src/index.md b/docs/src/index.md deleted file mode 100644 index 2539a4c..0000000 --- a/docs/src/index.md +++ /dev/null @@ -1,32 +0,0 @@ -# JSONSchema.jl - -JSONSchema.jl generates JSON Schema (draft-07) from Julia types and validates -instances against those schemas. It also supports validating data against -hand-written JSON Schema objects. - -## Installation - -```julia -using Pkg -Pkg.add("JSONSchema") -``` - -## Quick Start - -```julia -using JSONSchema -using StructUtils - -@defaults struct User - id::Int = 0 - name::String = "" &(json=(minLength=1,),) - email::String = "" &(json=(format="email",),) - age::Union{Int, Nothing} = nothing -end - -schema = JSONSchema.schema(User) -user = User(1, "Alice", "alice@example.com", 30) -result = JSONSchema.validate(schema, user) - -result.is_valid -``` diff --git a/docs/src/migration.md b/docs/src/migration.md deleted file mode 100644 index f6aa0fb..0000000 --- a/docs/src/migration.md +++ /dev/null @@ -1,190 +0,0 @@ -# v2.0 Migration Guide - -This guide helps you upgrade from JSONSchema.jl v1.x to v2.0. The v2.0 release is a -complete rewrite that changes the package from a pure validation library to a -schema generation and validation library. - -## Overview of Changes - -JSONSchema.jl v2.0 introduces: -- **Schema generation** from Julia types via `schema(T)` -- **Type-safe validation** with `Schema{T}` -- **StructUtils integration** for field-level validation rules -- **`$ref` support** for schema deduplication - -Most v1.x code will continue to work with minimal changes thanks to our -backwards compatibility layer. - -## Breaking Changes - -### 1. `parent_dir` Keyword Argument Removed - -The `Schema` constructor no longer accepts a `parent_dir` keyword argument for -resolving local file `$ref` references. - -**v1.x:** -```julia -schema = Schema(spec; parent_dir="./schemas") -``` - -**v2.0:** Local file reference resolution is not currently supported. External -`$ref` references should be resolved before creating the schema, or use the new -`refs` keyword argument with `schema()` for type-based deduplication. - -### 2. `SingleIssue` Type Replaced by `ValidationResult` - -The `SingleIssue` type from v1.x has been removed and replaced by -`ValidationResult`. - -**v1.x:** -```julia -result = validate(schema, data) -if result isa SingleIssue - println(result.x) # The invalid value - println(result.path) # JSON path to the error -end -``` - -**v2.0:** -```julia -result = validate(schema, data) -if result !== nothing - for err in result.errors - println(err) # Error message with path - end -end -``` - -### 3. `diagnose` Function - -The previously deprecated `diagnose` function has been removed. Use -`validate(schema, data)` instead. - -### 4. Inverse Argument Order - -The `validate` and `isvalid` functions where `schema` is the second argument -have been removed. `schema` must be the first argument. -```julia -validate(data, schema) # old -validate(schema, data) # new - -isvalid(data, schema) # old -isvalid(schema, data) # new -``` - -### 5. `required` Without `properties` - -v1.x supported non-standard schemas with `required` field and no `properties`. -In v2.0, you must specify `properties` if `required` is present. -```julia -schema = Schema(Dict("type" => "object", "required" => ["foo"])) # Not allowed -``` - -## API Compatibility - -The following v1.x patterns are fully supported in v2.0: - -### `validate()` Return Type (Unchanged) - -The `validate` function returns `nothing` on success and a `ValidationResult` -on failure, matching v1.x behavior: - -```julia -result = validate(schema, data) -if result === nothing - println("Valid!") -else - for err in result.errors - println(err) - end -end -``` - -### `isvalid()` Function - -The `isvalid` function extends `Base.isvalid` and returns a boolean: - -```julia -using JSONSchema - -isvalid(schema, data) # Returns true or false -``` - -### Boolean Schemas - -```julia -Schema(true) # Accepts everything -Schema(false) # Rejects everything -``` - -## New Features in v2.0 - -### Schema Generation from Types - -Generate JSON Schema directly from Julia struct definitions: - -```julia -using JSONSchema, StructUtils - -@defaults struct User - id::Int = 0 &(json=(minimum=1,),) - name::String = "" &(json=(minLength=1, maxLength=100),) - email::String = "" &(json=(format="email",),) - age::Union{Int, Nothing} = nothing &(json=(minimum=0, maximum=150),) -end - -schema = JSONSchema.schema(User) -``` - -### Type-Safe Validation - -Schemas are now parameterized by the type they describe: - -```julia -schema = JSONSchema.schema(User) # Returns Schema{User} -user = User(1, "Alice", "alice@example.com", 30) -isvalid(schema, user) # Type-safe validation -``` - -### `$ref` Support for Deduplication - -Use `refs=true` to generate schemas with `$ref` for nested types: - -```julia -@defaults struct Address - street::String = "" - city::String = "" -end - -@defaults struct Person - name::String = "" - address::Address = Address() -end - -schema = JSONSchema.schema(Person, refs=true) -# Generates schema with `$ref` to #/definitions/Address -``` - -### ValidationResult with Error Details - -Get detailed validation errors: - -```julia -result = JSONSchema.validate(schema, invalid_data) -if result !== nothing - for error in result.errors - println(error) # e.g., "name: string length 0 is less than minimum 1" - end -end -``` - -## Quick Migration Checklist - -- [ ] Remove `parent_dir` keyword from `Schema()` calls -- [ ] Update error handling to use `ValidationResult.errors` instead of `SingleIssue` fields -- [ ] Consider using `schema(T)` for type-based schema generation - -## Getting Help - -If you encounter issues migrating, please [open an issue](https://github.com/JuliaIO/JSONSchema.jl/issues) -with details about your use case. diff --git a/docs/src/reference.md b/docs/src/reference.md deleted file mode 100644 index 978380f..0000000 --- a/docs/src/reference.md +++ /dev/null @@ -1,5 +0,0 @@ -# API Reference - -```@autodocs -Modules = [JSONSchema] -``` diff --git a/docs/src/schema.md b/docs/src/schema.md deleted file mode 100644 index e8a8fda..0000000 --- a/docs/src/schema.md +++ /dev/null @@ -1,160 +0,0 @@ -# JSON Schema Generation and Validation - -JSONSchema.jl provides a powerful, type-driven interface for generating JSON Schema v7 specifications from Julia types and validating instances against them. The system leverages Julia's type system and `StructUtils` annotations to provide a seamless schema definition experience. - -## Quick Start - -```julia -using JSONSchema, StructUtils - -# Define a struct with field tag annotations -@defaults struct User - id::Int = 0 &(json=( - description="Unique user ID", - minimum=1 - ),) - - name::String = "" &(json=( - description="User's full name", - minLength=1, - maxLength=100 - ),) - - email::String = "" &(json=( - description="Email address", - format="email" - ),) - - age::Union{Int, Nothing} = nothing &(json=( - description="User's age", - minimum=0, - maximum=150 - ),) -end - -# Generate the JSON Schema -schema = JSONSchema.schema(User) - -# Validate an instance -user = User(1, "Alice", "alice@example.com", 30) -result = JSONSchema.validate(schema, user) - -if result.is_valid - println("User is valid!") -else - println("Validation errors:") - foreach(println, result.errors) -end -``` - -## API - -### `JSONSchema.schema(T; options...)` - -Generate a JSON Schema for type `T`. - -**Parameters:** -- `T::Type`: The Julia type to generate a schema for. -- `title::String`: Schema title (defaults to type name). -- `description::String`: Schema description. -- `refs::Bool`: If `true`, generates `definitions` for nested types and uses `$ref` pointers. Essential for circular references or shared types. -- `all_fields_required::Bool`: If `true`, marks all fields as required (overriding `Union{T, Nothing}` behavior). -- `additionalProperties::Bool`: Recursively sets `additionalProperties` on all objects. - -**Returns:** A `Schema{T}` object. - -### `JSONSchema.validate(schema, instance)` - -Validate a Julia instance against the schema. - -**Returns:** A `ValidationResult` struct: -- `is_valid::Bool`: `true` if validation passed. -- `errors::Vector{String}`: A list of error messages if validation failed. - -### `JSONSchema.isvalid(schema, instance; verbose=false)` - -Convenience function that returns a `Bool`. -- `verbose=true`: Prints validation errors to stdout. - -## Validation Features - -Validation rules are specified using `StructUtils` field tags with the `json` key. - -### String Validation -- `minLength::Int`, `maxLength::Int` -- `pattern::String` (Regex) -- `format::String`: - - `"email"`: Basic email validation (no spaces). - - `"uri"`: URI validation (requires scheme). - - `"uuid"`: UUID validation. - - `"date-time"`: ISO 8601 date-time (requires timezone, e.g., `2023-01-01T12:00:00Z`). - -### Numeric Validation -- `minimum::Number`, `maximum::Number` -- `exclusiveMinimum::Bool|Number`, `exclusiveMaximum::Bool|Number` -- `multipleOf::Number` - -### Array Validation -- `minItems::Int`, `maxItems::Int` -- `uniqueItems::Bool` -- `contains`: A schema that at least one item in the array must match. - -### Composition (Advanced) -- `oneOf`: Value must match exactly one of the provided schemas. -- `anyOf`: Value must match at least one of the provided schemas. -- `allOf`: Value must match all of the provided schemas. -- `not`: Value must *not* match the provided schema. - -**Example:** -```julia -# Value must be either a string OR an integer (oneOf) -val::Union{String, Int} = 0 - -# Advanced composition via manual tags -value::Int = 0 &(json=( - oneOf=[ - Dict("minimum" => 0, "maximum" => 10), - Dict("minimum" => 100, "maximum" => 110) - ] -),) -``` - -### Conditional Logic -- `if`, `then`, `else`: Apply schemas conditionally based on the result of the `if` schema. - -## Handling Complex Types - -### Recursive & Shared Types (`refs=true`) -By default, schemas are inlined. For complex data models with shared subtypes or circular references (e.g., A -> B -> A), use `refs=true`. - -```julia -@defaults struct Node - value::Int = 0 - children::Vector{Node} = Node[] -end - -# Generates a schema with "definitions" and "$ref" recursion -schema = JSONSchema.schema(Node, refs=true) -``` - -## Type Mapping - -| Julia Type | JSON Schema Type | Notes | -|------------|------------------|-------| -| `Int`, `Float64` | `"integer"`, `"number"` | | -| `String` | `"string"` | | -| `Bool` | `"boolean"` | | -| `Nothing`, `Missing` | `"null"` | | -| `Union{T, Nothing}` | `[T, "null"]` | Automatically optional | -| `Vector{T}` | `"array"` | `items` = schema of `T` | -| `Set{T}` | `"array"` | `uniqueItems: true` | -| `Dict{K,V}` | `"object"` | `additionalProperties` = schema of `V` | -| `Tuple{...}` | `"array"` | Fixed length, positional types | -| Custom Struct | `"object"` | Properties map to fields | - -## Best Practices - -1. **Use `JSONSchema.validate` for APIs:** It provides programmatic access to error messages, which is essential for reporting validation failures to users. -2. **Use Enums:** `enum=["a", "b"]` is often stricter and better than free-form strings. -3. **Use `refs=true` for Libraries:** If you are generating schemas for a library of types, using references keeps the schema size smaller and more readable. -4. **Be Specific with Formats:** The `date-time` format is strict (ISO 8601 with timezone). Ensure your data complies. diff --git a/ext/JSONSchemaJSON3Ext.jl b/ext/JSONSchemaJSON3Ext.jl new file mode 100644 index 0000000..07cbb3b --- /dev/null +++ b/ext/JSONSchemaJSON3Ext.jl @@ -0,0 +1,38 @@ +# Copyright (c) 2018: fredo-dedup and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +module JSONSchemaJSON3Ext + +import JSONSchema +import JSON3 + +_to_base_julia(x) = x + +_to_base_julia(x::JSON3.Array) = _to_base_julia.(x) + +# This method unintentionally allows JSON3.Object{Symbol,Any} objects as both +# data and the schema because it converts to Dict{String,Any}. Because we don't +# similarly convert Base.Dict, Dict{Symbol,Any} results in errors. This can be +# confusing to users. +# +# We can't make this method more restrictive because that would break backwards +# compatibility. For more details, see: +# https://github.com/fredo-dedup/JSONSchema.jl/issues/62 +function _to_base_julia(x::JSON3.Object) + return Dict{String,Any}(string(k) => _to_base_julia(v) for (k, v) in x) +end + +function JSONSchema.validate( + schema::JSONSchema.Schema, + x::Union{JSON3.Object,JSON3.Array}, +) + return JSONSchema.validate(schema, _to_base_julia(x)) +end + +function JSONSchema.Schema(schema::JSON3.Object; kwargs...) + return JSONSchema.Schema(_to_base_julia(schema); kwargs...) +end + +end diff --git a/src/JSONSchema.jl b/src/JSONSchema.jl index 041f71a..1fec843 100644 --- a/src/JSONSchema.jl +++ b/src/JSONSchema.jl @@ -1,4 +1,4 @@ -# Copyright (c) 2018-2026: fredo-dedup, quinnj, and contributors +# Copyright (c) 2018: fredo-dedup and contributors # # Use of this source code is governed by an MIT-style license that can be found # in the LICENSE.md file or at https://opensource.org/licenses/MIT. @@ -7,14 +7,24 @@ module JSONSchema import Downloads import JSON -import StructUtils import URIs -using JSON: JSONWriteStyle, Object -export Schema, SchemaContext, ValidationResult, schema, validate +export Schema, validate -include("utils.jl") -include("generation.jl") +include("schema.jl") include("validation.jl") +export diagnose +function diagnose(x, schema) + Base.depwarn( + "`diagnose(x, schema)` is deprecated. Use `validate(schema, x)` instead.", + :diagnose, + ) + ret = validate(schema, x) + if ret !== nothing + return sprint(show, ret) + end + return +end + end diff --git a/src/generation.jl b/src/generation.jl deleted file mode 100644 index afe83dc..0000000 --- a/src/generation.jl +++ /dev/null @@ -1,665 +0,0 @@ -# Copyright (c) 2018-2026: fredo-dedup, quinnj, and contributors -# -# Use of this source code is governed by an MIT-style license that can be found -# in the LICENSE.md file or at https://opensource.org/licenses/MIT. - -# JSON Schema generation from Julia types -# Provides a simple, convenient interface for generating JSON Schema v7 specifications - -# Helper functions for $ref support - -""" - defs_key_name(defs_location::Symbol) -> String - -Get the proper key name for definitions/defs. -Converts :defs to "\$defs" and :definitions to "definitions". -""" -function defs_key_name(defs_location::Symbol) - return defs_location == :defs ? "\$defs" : String(defs_location) -end - -""" - type_to_ref_name(::Type{T}) -> String - -Generate a reference name for a type. Uses fully qualified names for disambiguation. -""" -function type_to_ref_name(::Type{T}) where {T} - mod = T.name.module - typename = nameof(T) - - # Handle parametric types: Vector{Int} -> "Vector_Int" - if !isempty(T.parameters) && all(x -> x isa Type, T.parameters) - param_str = join([type_to_ref_name(p) for p in T.parameters], "_") - typename = "$(typename)_$(param_str)" - end - - # Create clean reference name - if mod === Main - return String(typename) - else - # Use module path for disambiguation - modpath = String(nameof(mod)) - return "$(modpath).$(typename)" - end -end - -""" - should_use_ref(::Type{T}, ctx::Union{Nothing, SchemaContext}) -> Bool - -Determine if a type should be referenced via \$ref instead of inlined. -""" -function should_use_ref(::Type{T}, ctx::Union{Nothing, SchemaContext}) where {T} - # Never use refs if no context provided - ctx === nothing && return false - - # Use ref for struct types that: - # 1. Are concrete types (can be instantiated) - # 2. Are struct types (not primitives) - # 3. Are user-defined (not from Base/Core) - - if !isconcretetype(T) || !isstructtype(T) - return false - end - - modname = string(T.name.module) - if modname in ("Core", "Base") || startswith(modname, "Base.") - return false - end - - return true -end - -""" - schema(T::Type; title=nothing, description=nothing, id=nothing, draft="https://json-schema.org/draft-07/schema#", all_fields_required=false, additionalProperties=nothing) - -Generate a JSON Schema for type `T`. The schema is returned as a JSON-serializable `Object`. - -# Keyword Arguments -- `all_fields_required::Bool=false`: If `true`, all fields of object schemas will be added to the required list. -- `additionalProperties::Union{Nothing,Bool}=nothing`: If `true` or `false`, sets `additionalProperties` recursively on the root and all child object schemas. If `nothing`, no additional action is taken. - -Field-level schema properties can be specified using StructUtils field tags with the `json` key: - -# Example -```julia -@defaults struct User - id::Int = 0 &(json=( - description="Unique user identifier", - minimum=1 - ),) - name::String = "" &(json=( - description="User's full name", - minLength=1, - maxLength=100 - ),) - email::Union{String, Nothing} = nothing &(json=( - description="Email address", - format="email" - ),) - age::Union{Int, Nothing} = nothing &(json=( - minimum=0, - maximum=150, - exclusiveMaximum=false - ),) -end - -schema = JSON.schema(User) -``` - -# Supported Field Tag Properties - -## String validation -- `minLength::Int`: Minimum string length -- `maxLength::Int`: Maximum string length -- `pattern::String`: Regular expression pattern (ECMA-262) -- `format::String`: Format hint (e.g., "email", "uri", "date-time", "uuid") - -## Numeric validation -- `minimum::Number`: Minimum value (inclusive) -- `maximum::Number`: Maximum value (inclusive) -- `exclusiveMinimum::Bool|Number`: Exclusive minimum -- `exclusiveMaximum::Bool|Number`: Exclusive maximum -- `multipleOf::Number`: Value must be multiple of this - -## Array validation -- `minItems::Int`: Minimum array length -- `maxItems::Int`: Maximum array length -- `uniqueItems::Bool`: All items must be unique - -## Object validation -- `minProperties::Int`: Minimum number of properties -- `maxProperties::Int`: Maximum number of properties - -## Generic -- `description::String`: Human-readable description -- `title::String`: Short title for the field -- `default::Any`: Default value -- `examples::Vector`: Example values -- `_const::Any`: Field must have this exact value (use `_const` since `const` is a reserved keyword) -- `enum::Vector`: Field must be one of these values -- `required::Bool`: Override required inference (default: true for non-Union{T,Nothing} types) - -## Composition -- `allOf::Vector{Type}`: Must validate against all schemas -- `anyOf::Vector{Type}`: Must validate against at least one schema -- `oneOf::Vector{Type}`: Must validate against exactly one schema - -The function automatically: -- Maps Julia types to JSON Schema types -- Marks non-`Nothing` union fields as required -- Handles nested types and arrays -- Supports custom types via registered converters - -# Returns -A `Schema{T}` object that contains both the type information and the JSON Schema specification. -The schema can be used for validation with `JSON.isvalid(schema, instance)`. -""" -function schema( - ::Type{T}; - title::Union{String, Nothing} = nothing, - description::Union{String, Nothing} = nothing, - id::Union{String, Nothing} = nothing, - draft::String = "https://json-schema.org/draft-07/schema#", - refs::Union{Bool, Symbol} = false, - context::Union{Nothing, SchemaContext} = nothing, - all_fields_required::Bool = false, - additionalProperties::Union{Nothing, Bool} = nothing - ) where {T} - - # Determine context based on parameters - ctx = if context !== nothing - context # Use provided context - elseif refs !== false - # Create new context based on refs option - defs_loc = refs === true ? :definitions : refs - SchemaContext(defs_loc) - else - nothing # No refs - use current inline behavior - end - - obj = Object{String, Any}() - obj["\$schema"] = draft - - if id !== nothing - obj["\$id"] = id - end - - if title !== nothing - obj["title"] = title - elseif hasproperty(T, :name) - obj["title"] = string(nameof(T)) - end - - if description !== nothing - obj["description"] = description - end - - # Generate the type schema and merge it (pass context and all_fields_required) - type_schema = _type_to_schema(T, ctx; all_fields_required = all_fields_required) - for (k, v) in type_schema - obj[k] = v - end - - # Add definitions if context was used - if ctx !== nothing && !isempty(ctx.definitions) - obj[defs_key_name(ctx.defs_location)] = ctx.definitions - end - - # Recursively set additionalProperties if specified - # This will process the root schema and all nested schemas, including definitions - if additionalProperties !== nothing - _set_additional_properties_recursive!(obj, additionalProperties, ctx) - end - - return Schema{T}(T, obj, ctx) -end - -# Internal: Convert a Julia type to JSON Schema representation -function _type_to_schema(::Type{T}, ctx::Union{Nothing, SchemaContext} = nothing; all_fields_required::Bool = false) where {T} - # Handle Any and abstract types specially to avoid infinite recursion - if T === Any - return Object{String, Any}() # Allow any type - end - - # Handle Union types (including Union{T, Nothing}) - if T isa Union - return _union_to_schema(T, ctx; all_fields_required = all_fields_required) - end - - # Primitive types (check Bool first since Bool <: Integer in Julia!) - if T === Bool - return Object{String, Any}("type" => "boolean") - elseif T === Nothing || T === Missing - return Object{String, Any}("type" => "null") - elseif T === Int || T === Int64 || T === Int32 || T === Int16 || T === Int8 || - T === UInt || T === UInt64 || T === UInt32 || T === UInt16 || T === UInt8 || - T <: Integer - return Object{String, Any}("type" => "integer") - elseif T === Float64 || T === Float32 || T <: AbstractFloat - return Object{String, Any}("type" => "number") - elseif T === String || T <: AbstractString - return Object{String, Any}("type" => "string") - end - - # Handle parametric types - if T <: AbstractVector - return _array_to_schema(T, ctx; all_fields_required = all_fields_required) - elseif T <: AbstractDict - return _dict_to_schema(T, ctx; all_fields_required = all_fields_required) - elseif T <: AbstractSet - return _set_to_schema(T, ctx; all_fields_required = all_fields_required) - elseif T <: Tuple - return _tuple_to_schema(T, ctx; all_fields_required = all_fields_required) - end - - # Struct types - try to process user-defined structs - if isconcretetype(T) && !isabstracttype(T) && isstructtype(T) - # Avoid processing internal compiler types that could cause issues - modname = string(T.name.module) - if (T <: NamedTuple) || (!(modname in ("Core", "Base")) && !startswith(modname, "Base.")) - try - # Check if we should use $ref for this struct - if should_use_ref(T, ctx) - return _struct_to_schema_with_refs(T, ctx; all_fields_required = all_fields_required) - else - return _struct_to_schema_core(T, ctx; all_fields_required = all_fields_required) - end - catch - # If struct processing fails, fall through to fallback - end - end - end - - # Fallback: allow any type - return Object{String, Any}() -end - -# Handle Union types -function _union_to_schema(::Type{T}, ctx::Union{Nothing, SchemaContext} = nothing; all_fields_required::Bool = false) where {T} - types = Base.uniontypes(T) - - # Special case: Union{T, Nothing} - make nullable - if length(types) == 2 && (Nothing in types || Missing in types) - non_null_type = types[1] === Nothing || types[1] === Missing ? types[2] : types[1] - schema = _type_to_schema(non_null_type, ctx; all_fields_required = all_fields_required) - - # If the schema is a $ref, we need to use oneOf (can't mix $ref with other properties) - if haskey(schema, "\$ref") - obj = Object{String, Any}() - obj["oneOf"] = [schema, Object{String, Any}("type" => "null")] - return obj - end - - # Otherwise, add null as allowed type - if haskey(schema, "type") - if schema["type"] isa Vector - push!(schema["type"], "null") - else - schema["type"] = [schema["type"], "null"] - end - else - schema["type"] = "null" - end - - return schema - end - - # General union: use oneOf (exactly one must match) - # Note: We use oneOf instead of anyOf because Julia's Union types - # require the value to be exactly one of the types, not multiple - obj = Object{String, Any}() - obj["oneOf"] = [_type_to_schema(t, ctx; all_fields_required = all_fields_required) for t in types] - return obj -end - -# Handle array types -function _array_to_schema(::Type{T}, ctx::Union{Nothing, SchemaContext} = nothing; all_fields_required::Bool = false) where {T} - obj = Object{String, Any}("type" => "array") - - # Get element type - if T <: AbstractVector - eltype_t = eltype(T) - obj["items"] = _type_to_schema(eltype_t, ctx; all_fields_required = all_fields_required) - end - - return obj -end - -# Handle dictionary types -function _dict_to_schema(::Type{T}, ctx::Union{Nothing, SchemaContext} = nothing; all_fields_required::Bool = false) where {T} - obj = Object{String, Any}("type" => "object") - - # Get value type for additionalProperties - if T <: AbstractDict - valtype_t = valtype(T) - if valtype_t !== Union{} - # For Any type, we return an empty schema which means "allow anything" - obj["additionalProperties"] = _type_to_schema(valtype_t, ctx; all_fields_required = all_fields_required) - end - end - - return obj -end - -# Handle set types -function _set_to_schema(::Type{T}, ctx::Union{Nothing, SchemaContext} = nothing; all_fields_required::Bool = false) where {T} - obj = Object{String, Any}("type" => "array") - obj["uniqueItems"] = true - - # Get element type - if T <: AbstractSet - eltype_t = eltype(T) - obj["items"] = _type_to_schema(eltype_t, ctx; all_fields_required = all_fields_required) - end - - return obj -end - -# Handle tuple types -function _tuple_to_schema(::Type{T}, ctx::Union{Nothing, SchemaContext} = nothing; all_fields_required::Bool = false) where {T} - obj = Object{String, Any}("type" => "array") - - # Tuples have fixed-length items with specific types - # JSON Schema Draft 7 uses "items" as an array for tuple validation - if T.parameters !== () && all(x -> x isa Type, T.parameters) - obj["items"] = [_type_to_schema(t, ctx; all_fields_required = all_fields_required) for t in T.parameters] - obj["minItems"] = length(T.parameters) - obj["maxItems"] = length(T.parameters) - end - - return obj -end - -# Handle struct types with $ref support (circular reference detection) -function _struct_to_schema_with_refs(::Type{T}, ctx::SchemaContext; all_fields_required::Bool = false) where {T} - # Get the proper key name for definitions - defs_key = defs_key_name(ctx.defs_location) - - # Check if we're already generating this type (circular reference!) - if T in ctx.generation_stack - # Generate $ref immediately - definition will be completed later - ref_name = type_to_ref_name(T) - ctx.type_names[T] = ref_name - return Object{String, Any}("\$ref" => "#/$(defs_key)/$(ref_name)") - end - - # Check if already defined (deduplication) - if haskey(ctx.type_names, T) - ref_name = ctx.type_names[T] - return Object{String, Any}("\$ref" => "#/$(defs_key)/$(ref_name)") - end - - # Mark as being generated (prevents infinite recursion) - push!(ctx.generation_stack, T) - ref_name = type_to_ref_name(T) - ctx.type_names[T] = ref_name - - try - # Generate the actual schema (may recursively call this function) - schema_obj = _struct_to_schema_core(T, ctx; all_fields_required = all_fields_required) - - # Store in definitions - ctx.definitions[ref_name] = schema_obj - - # Return a reference - return Object{String, Any}("\$ref" => "#/$(defs_key)/$(ref_name)") - finally - # Always pop from stack, even if error occurs - pop!(ctx.generation_stack) - end -end - -# Handle struct types (core logic without ref handling) -function _struct_to_schema_core(::Type{T}, ctx::Union{Nothing, SchemaContext} = nothing; all_fields_required::Bool = false) where {T} - obj = Object{String, Any}("type" => "object") - properties = Object{String, Any}() - required = String[] - - # Iterate over fields - if fieldcount(T) == 0 - obj["properties"] = properties - return obj - end - - style = StructUtils.DefaultStyle() - # Get all field tags at once (returns NamedTuple with field names as keys) - all_field_tags = StructUtils.fieldtags(style, T) - - for i in 1:fieldcount(T) - fname = fieldname(T, i) - ftype = fieldtype(T, i) - - # Get field tags for this specific field - tags = _json_field_tags(all_field_tags, fname) - - # Skip ignored fields - if _field_ignored(tags) - continue - end - - # Determine JSON key name (may be renamed via tags) - json_name = _json_field_name(fname, tags) - - # Generate schema for this field (pass context for ref support) - field_schema = _type_to_schema(ftype, ctx; all_fields_required = all_fields_required) - - # Apply field tags to schema - if tags isa NamedTuple - _apply_field_tags!(field_schema, tags, ftype) - end - - # Check if field should be required - is_required = all_fields_required || _is_required_field(ftype, tags) - if is_required - push!(required, json_name) - end - - properties[json_name] = field_schema - end - - if length(properties) > 0 - obj["properties"] = properties - end - - if length(required) > 0 - obj["required"] = required - end - - return obj -end - -# Determine if a field is required -function _is_required_field(::Type{T}, tags) where {T} - # Check explicit required tag - if tags isa NamedTuple && haskey(tags, :required) - return Bool(tags.required) - end - - # By default, Union{T, Nothing} fields are optional - if T isa Union - types = Base.uniontypes(T) - if Nothing in types || Missing in types - return false - end - end - - # All other fields are required by default - return true -end - -# Recursively set additionalProperties on all object schemas -function _set_additional_properties_recursive!(schema_obj::Object{String, Any}, value::Bool, ctx::Union{Nothing, SchemaContext}) - # Skip $ref schemas - they're references, not actual schemas - if haskey(schema_obj, "\$ref") - return - end - - # Set additionalProperties on object schemas - # Check if it's an object type or has properties (which indicates an object schema) - if (haskey(schema_obj, "type") && schema_obj["type"] == "object") || haskey(schema_obj, "properties") - schema_obj["additionalProperties"] = value - end - - # Recursively process nested schemas - # Properties - if haskey(schema_obj, "properties") - for (_, prop_schema) in schema_obj["properties"] - if prop_schema isa Object{String, Any} - _set_additional_properties_recursive!(prop_schema, value, ctx) - end - end - end - - # Items (for arrays) - if haskey(schema_obj, "items") - items = schema_obj["items"] - if items isa Object{String, Any} - _set_additional_properties_recursive!(items, value, ctx) - elseif items isa AbstractVector - for item_schema in items - if item_schema isa Object{String, Any} - _set_additional_properties_recursive!(item_schema, value, ctx) - end - end - end - end - - # Composition schemas - for key in ["allOf", "anyOf", "oneOf"] - if haskey(schema_obj, key) && schema_obj[key] isa AbstractVector - for sub_schema in schema_obj[key] - if sub_schema isa Object{String, Any} - _set_additional_properties_recursive!(sub_schema, value, ctx) - end - end - end - end - - # Conditional schemas - for key in ["if", "then", "else"] - if haskey(schema_obj, key) && schema_obj[key] isa Object{String, Any} - _set_additional_properties_recursive!(schema_obj[key], value, ctx) - end - end - - # Not schema - if haskey(schema_obj, "not") && schema_obj["not"] isa Object{String, Any} - _set_additional_properties_recursive!(schema_obj["not"], value, ctx) - end - - # Contains schema (for arrays) - if haskey(schema_obj, "contains") && schema_obj["contains"] isa Object{String, Any} - _set_additional_properties_recursive!(schema_obj["contains"], value, ctx) - end - - # Pattern properties - if haskey(schema_obj, "patternProperties") - for (_, pattern_schema) in schema_obj["patternProperties"] - if pattern_schema isa Object{String, Any} - _set_additional_properties_recursive!(pattern_schema, value, ctx) - end - end - end - - # Property names schema - if haskey(schema_obj, "propertyNames") && schema_obj["propertyNames"] isa Object{String, Any} - _set_additional_properties_recursive!(schema_obj["propertyNames"], value, ctx) - end - - # Additional items (for tuples) - if haskey(schema_obj, "additionalItems") && schema_obj["additionalItems"] isa Object{String, Any} - _set_additional_properties_recursive!(schema_obj["additionalItems"], value, ctx) - end - - # Dependencies (schema-based) - if haskey(schema_obj, "dependencies") - for (_, dep) in schema_obj["dependencies"] - if dep isa Object{String, Any} - _set_additional_properties_recursive!(dep, value, ctx) - end - end - end - - # Definitions/$defs (process all definitions recursively) - for defs_key in ["definitions", "\$defs"] - if haskey(schema_obj, defs_key) && schema_obj[defs_key] isa Object{String, Any} - for (_, def_schema) in schema_obj[defs_key] - if def_schema isa Object{String, Any} - _set_additional_properties_recursive!(def_schema, value, ctx) - end - end - end - end - return -end - -# Apply field tags to a schema object -function _apply_field_tags!(schema::Object{String, Any}, tags::NamedTuple, ftype::Type) - # String validation - haskey(tags, :minLength) && (schema["minLength"] = tags.minLength) - haskey(tags, :maxLength) && (schema["maxLength"] = tags.maxLength) - haskey(tags, :pattern) && (schema["pattern"] = tags.pattern) - haskey(tags, :format) && (schema["format"] = string(tags.format)) - - # Numeric validation - haskey(tags, :minimum) && (schema["minimum"] = tags.minimum) - haskey(tags, :maximum) && (schema["maximum"] = tags.maximum) - haskey(tags, :exclusiveMinimum) && (schema["exclusiveMinimum"] = tags.exclusiveMinimum) - haskey(tags, :exclusiveMaximum) && (schema["exclusiveMaximum"] = tags.exclusiveMaximum) - haskey(tags, :multipleOf) && (schema["multipleOf"] = tags.multipleOf) - - # Array validation - haskey(tags, :minItems) && (schema["minItems"] = tags.minItems) - haskey(tags, :maxItems) && (schema["maxItems"] = tags.maxItems) - haskey(tags, :uniqueItems) && (schema["uniqueItems"] = tags.uniqueItems) - - # Items schema (can be single schema or array for tuple validation) - if haskey(tags, :items) - items = tags.items - if items isa AbstractVector - # Tuple validation: array of schemas - schema["items"] = [item isa Type ? _type_to_schema(item) : item for item in items] - else - # Single schema applies to all items - schema["items"] = items isa Type ? _type_to_schema(items) : items - end - end - - # Object validation - haskey(tags, :minProperties) && (schema["minProperties"] = tags.minProperties) - haskey(tags, :maxProperties) && (schema["maxProperties"] = tags.maxProperties) - - # Generic properties - haskey(tags, :description) && (schema["description"] = string(tags.description)) - haskey(tags, :title) && (schema["title"] = string(tags.title)) - haskey(tags, :examples) && (schema["examples"] = collect(tags.examples)) - (haskey(tags, :_const) || haskey(tags, Symbol("const"))) && (schema["const"] = get(tags, :_const, get(tags, Symbol("const"), nothing))) - haskey(tags, :enum) && (schema["enum"] = collect(tags.enum)) - - # Default value - if haskey(tags, :default) - schema["default"] = tags.default - end - - # Composition (allOf, anyOf, oneOf) - # These can be either Type objects or Dict/Object schemas - if haskey(tags, :allOf) && tags.allOf isa Vector - schema["allOf"] = [t isa Type ? _type_to_schema(t) : t for t in tags.allOf] - end - if haskey(tags, :anyOf) && tags.anyOf isa Vector - schema["anyOf"] = [t isa Type ? _type_to_schema(t) : t for t in tags.anyOf] - end - if haskey(tags, :oneOf) && tags.oneOf isa Vector - schema["oneOf"] = [t isa Type ? _type_to_schema(t) : t for t in tags.oneOf] - end - - # Negation (not) - if haskey(tags, :not) - schema["not"] = tags.not isa Type ? _type_to_schema(tags.not) : tags.not - end - - # Array contains - return if haskey(tags, :contains) - schema["contains"] = tags.contains isa Type ? _type_to_schema(tags.contains) : tags.contains - end -end diff --git a/src/schema.jl b/src/schema.jl new file mode 100644 index 0000000..2e36033 --- /dev/null +++ b/src/schema.jl @@ -0,0 +1,297 @@ +# Copyright (c) 2018: fredo-dedup and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +# Transform escaped characters in JPaths back to their original value. +function unescape_jpath(raw::String) + ret = replace(replace(raw, "~0" => "~"), "~1" => "/") + m = match(r"%([0-9A-F]{2})", ret) + if m !== nothing + for c in m.captures + ret = replace(ret, "%$(c)" => Char(parse(UInt8, "0x$(c)"))) + end + end + return ret +end + +function type_to_dict(x) + return Dict(name => getfield(x, name) for name in fieldnames(typeof(x))) +end + +function update_id(uri::URIs.URI, s::String) + id2 = URIs.URI(s) + if !isempty(id2.scheme) || (isempty(string(uri)) && startswith(s, "#")) + return id2 + end + els = type_to_dict(uri) + delete!(els, :uri) + els[:fragment] = id2.fragment + if !isempty(id2.path) + if startswith(id2.path, "/") # Absolute path + els[:path] = id2.path + else # Relative path + old_path = match(r"^(.*/).*$", uri.path) + if old_path === nothing + els[:path] = id2.path + else + els[:path] = old_path.captures[1] * id2.path + end + end + end + return URIs.URI(; els...) +end + +function get_element(schema, path::AbstractString) + elements = split(path, "/"; keepempty = true) + if isempty(first(elements)) + popfirst!(elements) + end + for element in elements + schema = _recurse_get_element(schema, unescape_jpath(String(element))) + end + return schema +end + +function _recurse_get_element(schema::Any, ::String) + return error( + "unmanaged type in ref resolution $(typeof(schema)): $(schema).", + ) +end + +function _recurse_get_element(schema::AbstractDict, element::String) + if !haskey(schema, element) + error("missing property '$(element)' in $(schema).") + end + return schema[element] +end + +function _recurse_get_element(schema::AbstractVector, element::String) + index = tryparse(Int, element) # Remember that `index` is 0-indexed! + if index === nothing + error("expected integer array index instead of '$(element)'.") + elseif index >= length(schema) + error("item index $(index) is larger than array $(schema).") + end + return schema[index+1] +end + +function get_remote_schema(uri::URIs.URI) + io = IOBuffer() + r = Downloads.request(string(uri); output = io, throw = false) + if r isa Downloads.Response && r.status == 200 + return Schema(JSON.parse(seekstart(io))) + end + msg = "Unable to get remote schema at $uri" + if r isa Downloads.RequestError + msg *= ": " * r.message + elseif r isa Downloads.Response + msg *= ": HTTP status code $(r.status)" + end + return error(msg) +end + +function find_ref( + uri::URIs.URI, + id_map::AbstractDict, + path::String, + parent_dir::String, +) + if haskey(id_map, path) + return id_map[path] # An exact path exists. Get it. + elseif path == "" || path == "#" # This path refers to the root schema. + return id_map[string(uri)] + elseif startswith(path, "#/") # This path is a JPointer. + return get_element(id_map[string(uri)], path[3:end]) + end + uri = update_id(uri, path) + els = type_to_dict(uri) + delete!.(Ref(els), [:uri, :fragment]) + uri2 = URIs.URI(; els...) + is_file_uri = startswith(uri2.scheme, "file") || isempty(uri2.scheme) + if is_file_uri && !isabspath(uri2.path) + # Normalize a file path to an absolute path so creating a key is consistent. + uri2 = URIs.URI(uri2; path = abspath(joinpath(parent_dir, uri2.path))) + end + if !haskey(id_map, string(uri2)) + # id_map doesn't have this key so, fetch the ref and add it to id_map. + if startswith(uri2.scheme, "http") + @info("fetching remote ref $(uri2)") + id_map[string(uri2)] = get_remote_schema(uri2).data + else + @assert is_file_uri + @info("loading local ref $(uri2)") + local_schema = Schema( + JSON.parsefile(uri2.path); + parent_dir = dirname(uri2.path), + ) + id_map[string(uri2)] = local_schema.data + end + end + return get_element(id_map[string(uri2)], uri.fragment) +end + +# Recursively find all "$ref" fields and resolve their path. + +resolve_refs!(::Any, ::URIs.URI, ::AbstractDict, ::String) = nothing + +function resolve_refs!( + schema::AbstractVector, + uri::URIs.URI, + id_map::AbstractDict, + parent_dir::String, +) + for s in schema + resolve_refs!(s, uri, id_map, parent_dir) + end + return +end + +function resolve_refs!( + schema::AbstractDict, + uri::URIs.URI, + id_map::AbstractDict, + parent_dir::String, +) + # This $ref has not been resolved yet (otherwise it would not be a String). + # We will replace the path string with the schema element pointed at, thus + # marking it as resolved. This should prevent infinite recursions caused by + # self referencing. We also unpack the $ref first so that fields like $id + # do not interfere with it. + ref = get(schema, "\$ref", nothing) + ref_unpacked = false + if ref isa String + schema["\$ref"] = find_ref(uri, id_map, ref, parent_dir) + ref_unpacked = true + end + if haskey(schema, "id") && schema["id"] isa String + # This block is for draft 4. + uri = update_id(uri, schema["id"]) + end + if haskey(schema, "\$id") && schema["\$id"] isa String + # This block is for draft 6+. + uri = update_id(uri, schema["\$id"]) + end + for (k, v) in schema + if k == "\$ref" && ref_unpacked + continue # We've already unpacked this ref + elseif k in ("enum", "const") + continue # Don't unpack refs inside const and enum. + else + resolve_refs!(v, uri, id_map, parent_dir) + end + end + return +end + +function build_id_map(schema::AbstractDict) + id_map = Dict{String,Any}("" => schema) + build_id_map!(id_map, schema, URIs.URI()) + return id_map +end + +build_id_map!(::AbstractDict, ::Any, ::URIs.URI) = nothing + +function build_id_map!( + id_map::AbstractDict, + schema::AbstractVector, + uri::URIs.URI, +) + build_id_map!.(Ref(id_map), schema, Ref(uri)) + return +end + +function build_id_map!( + id_map::AbstractDict, + schema::AbstractDict, + uri::URIs.URI, +) + if haskey(schema, "id") && schema["id"] isa String + # This block is for draft 4. + uri = update_id(uri, schema["id"]) + id_map[string(uri)] = schema + end + if haskey(schema, "\$id") && schema["\$id"] isa String + # This block is for draft 6+. + uri = update_id(uri, schema["\$id"]) + id_map[string(uri)] = schema + end + for (k, value) in schema + if k == "enum" || k == "const" + continue + end + build_id_map!(id_map, value, uri) + end + return +end + +""" + Schema(schema::AbstractDict; parent_dir::String = abspath(".")) + +Create a schema but with `schema` being a parsed JSON created with `JSON.parse()` +or `JSON.parsefile()`. + +`parent_dir` is the path with respect to which references to local schemas are +resolved. + +## Examples + +```julia +my_schema = Schema(JSON.parsefile(filename)) +my_schema = Schema(JSON.parsefile(filename); parent_dir = "~/schemas") +``` +""" +struct Schema + data::Union{AbstractDict,Bool} + + Schema(schema::Bool; kwargs...) = new(schema) + + function Schema( + schema::AbstractDict; + parent_dir::String = abspath("."), + parentFileDirectory = nothing, + ) + if parentFileDirectory !== nothing + @warn( + "kwarg `parentFileDirectory` is deprecated. Use `parent_dir` instead." + ) + parent_dir = parentFileDirectory + end + schema = deepcopy(schema) # Ensure we don't modify the user's data! + id_map = build_id_map(schema) + resolve_refs!(schema, URIs.URI(), id_map, parent_dir) + return new(schema) + end +end + +""" + Schema(schema::String; parent_dir::String = abspath(".")) + +Create a schema for document validation by parsing the string `schema`. + +`parent_dir` is the path with respect to which references to local schemas are +resolved. + +## Examples + +```julia +my_schema = Schema(\"\"\"{ + \"properties\": { + \"foo\": {}, + \"bar\": {} + }, + \"required\": [\"foo\"] +}\"\"\") + +# Assume there exists `~/schemas/local_file.json`: +my_schema = Schema( + \"\"\"{ + "\$ref": "local_file.json" + }\"\"\", + parent_dir = "~/schemas" +) +``` +""" +Schema(schema::String; kwargs...) = Schema(JSON.parse(schema); kwargs...) + +Base.show(io::IO, ::Schema) = print(io, "A JSONSchema") diff --git a/src/utils.jl b/src/utils.jl deleted file mode 100644 index 907c23f..0000000 --- a/src/utils.jl +++ /dev/null @@ -1,102 +0,0 @@ -# Copyright (c) 2018-2026: fredo-dedup, quinnj, and contributors -# -# Use of this source code is governed by an MIT-style license that can be found -# in the LICENSE.md file or at https://opensource.org/licenses/MIT. - -# Shared types and helpers for schema generation and validation - -# Context for tracking type definitions during schema generation with $ref support -mutable struct SchemaContext - # Map from Type to definition name - type_names::Dict{Type, String} - # Map from definition name to schema - definitions::Object{String, Any} - # Stack to detect circular references during generation - generation_stack::Vector{Type} - # Where to store definitions: :definitions (Draft 7) or :defs (Draft 2019+) - defs_location::Symbol - - SchemaContext(defs_location::Symbol = :definitions) = new( - Dict{Type, String}(), - Object{String, Any}(), - Type[], - defs_location - ) -end - -""" - Schema{T} - -A typed JSON Schema for type `T`. Contains the schema specification and can be used -for validation via `isvalid` (which overloads `Base.isvalid`). - -# Fields -- `type::Type{T}`: The Julia type this schema describes -- `spec::Object{String, Any}`: The JSON Schema specification - -# Example -```julia -using JSONSchema, StructUtils - -@defaults struct User - name::String = "" - email::String = "" - age::Int = 0 -end - -schema = JSONSchema.schema(User) -instance = User("alice", "alice@example.com", 25) -isvalid(schema, instance) # returns true -``` -""" -struct Schema{T} - type::Type{T} - spec::Object{String, Any} - context::Union{Nothing, SchemaContext} - - # Existing constructor (unchanged for backwards compatibility) - Schema{T}(type::Type{T}, spec::Object{String, Any}) where {T} = new{T}(type, spec, nothing) - # New constructor with context - Schema{T}(type::Type{T}, spec::Object{String, Any}, ctx::Union{Nothing, SchemaContext}) where {T} = new{T}(type, spec, ctx) -end - -Base.getindex(s::Schema, key) = s.spec[key] -Base.haskey(s::Schema, key) = haskey(s.spec, key) -Base.keys(s::Schema) = keys(s.spec) -Base.get(s::Schema, key, default) = get(s.spec, key, default) - -# Constructors for creating Schema from spec objects (for test suite compatibility) -function Schema(spec) - spec_obj = spec isa Object ? spec : Object{String, Any}(spec) - return Schema{Any}(Any, spec_obj, nothing) -end -Schema(spec::AbstractString) = Schema(JSON.parse(spec)) -Schema(spec::AbstractVector{UInt8}) = Schema(JSON.parse(spec)) - -# Boolean schemas are part of the draft6 specification. -function Schema(b::Bool) - if b - # true schema accepts everything - empty schema - return Schema{Any}(Any, Object{String, Any}(), nothing) - else - # false schema rejects everything - use "not: {}" pattern - return Schema{Any}(Any, Object{String, Any}("not" => Object{String, Any}()), nothing) - end -end - -# Internal helpers for field tags and JSON names. -function _json_field_tags(all_field_tags, fname::Symbol) - field_tags = haskey(all_field_tags, fname) ? all_field_tags[fname] : nothing - return field_tags isa NamedTuple && haskey(field_tags, :json) ? field_tags.json : nothing -end - -function _json_field_name(fname::Symbol, tags) - return tags isa NamedTuple && haskey(tags, :name) ? string(tags.name) : string(fname) -end - -function _field_ignored(tags) - return tags isa NamedTuple && get(tags, :ignore, false) -end - -# Allow JSON serialization of Schema objects -StructUtils.lower(::JSONWriteStyle, s::Schema) = s.spec diff --git a/src/validation.jl b/src/validation.jl index e95b3b6..ad5d6b1 100644 --- a/src/validation.jl +++ b/src/validation.jl @@ -1,791 +1,796 @@ -# Copyright (c) 2018-2026: fredo-dedup, quinnj, and contributors +# Copyright (c) 2018: fredo-dedup and contributors # # Use of this source code is governed by an MIT-style license that can be found # in the LICENSE.md file or at https://opensource.org/licenses/MIT. -# Validation functionality - -# Helper: Resolve a $ref reference -function _resolve_ref(ref_path::String, root_schema::Object{String, Any}) - # Handle JSON Pointer syntax: "#/definitions/User" or "#/$defs/User" - if startswith(ref_path, "#/") - parts = split(ref_path[3:end], '/') # Skip "#/" - current = root_schema - for part in parts - # Convert SubString to String for Object key lookup - key = String(part) - if !haskey(current, key) - error("Reference not found: $ref_path") - end - current = current[key] - end - return current - end +struct SingleIssue + x::Any + path::String + reason::String + val::Any +end - error("External refs not supported: $ref_path") +function Base.show(io::IO, issue::SingleIssue) + return println( + io, + """Validation failed: +path: $(isempty(issue.path) ? "top-level" : issue.path) +instance: $(issue.x) +schema key: $(issue.reason) +schema value: $(issue.val)""", + ) end """ - ValidationResult + validate(s::Schema, x) -Result of a schema validation operation. +Validate the object `x` against the Schema `s`. If valid, return `nothing`, else +return a `SingleIssue`. When printed, the returned `SingleIssue` describes the +reason why the validation failed. -# Fields -- `is_valid::Bool`: Whether the validation was successful -- `errors::Vector{String}`: List of validation error messages (empty if valid) -""" -struct ValidationResult - is_valid::Bool - errors::Vector{String} -end -""" - validate(schema::Schema{T}, instance::T) -> Union{Nothing, ValidationResult} +Note that if `x` is a `String` in JSON format, you must use `JSON.parse(x)` +before passing to `validate`, that is, JSONSchema operates on the parsed +representation, not on the underlying `String` representation of the JSON data. -Validate that `instance` satisfies all constraints defined in `schema`. -Returns `nothing` if valid, or a `ValidationResult` containing error messages if invalid. +## Examples -# Example ```julia -result = validate(schema, instance) -if result !== nothing - for err in result.errors - println(err) - end -end +julia> schema = Schema( + Dict( + "properties" => Dict( + "foo" => Dict(), + "bar" => Dict() + ), + "required" => ["foo"] + ) + ) +Schema + +julia> data_pass = Dict("foo" => true) +Dict{String,Bool} with 1 entry: + "foo" => true + +julia> data_fail = Dict("bar" => 12.5) +Dict{String,Float64} with 1 entry: + "bar" => 12.5 + +julia> validate(data_pass, schema) + +julia> validate(data_fail, schema) +Validation failed: +path: top-level +instance: Dict("bar"=>12.5) +schema key: required +schema value: ["foo"] ``` """ -function validate(schema::Schema{T}, instance::T; resolver = nothing) where {T} - errors = String[] - # Pass root schema for $ref resolution - _validate_instance(schema.spec, instance, T, "", errors, false, schema.spec) - return isempty(errors) ? nothing : ValidationResult(false, errors) -end - -# Also support JSON.Schema (which is an alias for JSONSchema.Schema) -# and inverse argument order for v1.5.0 compatibility -function validate(schema, instance; resolver = nothing) - # Handle JSON.Schema (which is aliased to JSONSchema.Schema) - if typeof(schema).name.module === JSON && hasfield(typeof(schema), :type) && hasfield(typeof(schema), :spec) - return validate(Schema{typeof(schema).parameters[1]}(schema.type, schema.spec, nothing), instance; resolver = resolver) - end - error("Unsupported schema type: $(typeof(schema))") -end - -# Minimal RefResolver for test suite compatibility -mutable struct RefResolver - root::Any - store::Dict{String, Any} - base_map::IdDict{Any, String} - seen::IdDict{Any, Bool} - loaded::Dict{String, Bool} - remote_loader::Union{Nothing, Function} -end - -function RefResolver(root; base_uri::AbstractString = "", remote_loader = nothing) - resolver = RefResolver( - root, - Dict{String, Any}(), - IdDict{Any, String}(), - IdDict{Any, Bool}(), - Dict{String, Bool}(), - remote_loader - ) - return resolver +function validate(schema::Schema, x) + return _validate(x, schema.data, "") end -""" - Base.isvalid(schema::Schema{T}, instance::T; verbose=false) -> Bool - -Validate that `instance` satisfies all constraints defined in `schema`. - -This function extends `Base.isvalid` and checks that the instance meets all -validation requirements specified in the schema's field tags, including: -- String constraints (minLength, maxLength, pattern, format) -- Numeric constraints (minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf) -- Array constraints (minItems, maxItems, uniqueItems) -- Enum and const values -- Nested struct validation - -# Arguments -- `schema::Schema{T}`: The schema to validate against -- `instance::T`: The instance to validate -- `verbose::Bool=false`: If true, print detailed validation errors to stdout +Base.isvalid(schema::Schema, x) = validate(schema, x) === nothing -# Returns -`true` if the instance is valid, `false` otherwise +# Fallbacks for the opposite argument. +validate(x, schema::Schema) = validate(schema, x) +Base.isvalid(x, schema::Schema) = isvalid(schema, x) -# Example -```julia -using JSONSchema, StructUtils - -@defaults struct User - name::String = "" &(json=(minLength=1, maxLength=100),) - age::Int = 0 &(json=(minimum=0, maximum=150),) +function _validate(x, schema, path::String) + schema = _resolve_refs(schema) + return _validate_entry(x, schema, path) end -schema = JSONSchema.schema(User) -user1 = User("Alice", 25) -user2 = User("", 200) # Invalid: empty name, age too high - -isvalid(schema, user1) # true -isvalid(schema, user2) # false -isvalid(schema, user2; verbose=true) # false, with error messages -``` -""" -function Base.isvalid(schema::Schema{T}, instance::T; verbose::Bool = false) where {T} - result = validate(schema, instance) - is_valid = result === nothing - - if verbose && !is_valid - for err in result.errors - println(" x ", err) +function _validate_entry(x, schema::AbstractDict, path) + for (k, v) in schema + ret = _validate(x, schema, Val{Symbol(k)}(), v, path) + if ret !== nothing + return ret end end + return +end - return is_valid +function _validate_entry(x, schema::Bool, path::String) + if !schema + return SingleIssue(x, path, "schema", schema) + end + return end -# Internal: Validate an instance against a schema -function _validate_instance(schema_obj, instance, ::Type{T}, path::String, errors::Vector{String}, verbose::Bool, root::Object{String, Any}) where {T} - # Handle $ref - resolve and validate against resolved schema - if haskey(schema_obj, "\$ref") - ref_path = schema_obj["\$ref"] - try - resolved_schema = _resolve_ref(ref_path, root) - return _validate_instance(resolved_schema, instance, T, path, errors, verbose, root) - catch e - push!(errors, "$path: error resolving \$ref: $(e.msg)") - return - end +function _resolve_refs(schema::AbstractDict, explored_refs = Any[schema]) + if !haskey(schema, "\$ref") + return schema + end + schema = schema["\$ref"] + if any(x -> x === schema, explored_refs) + error("cannot support circular references in schema.") end + push!(explored_refs, schema) + return _resolve_refs(schema, explored_refs) +end +_resolve_refs(schema, explored_refs = Any[]) = schema - # Handle structs - if isstructtype(T) && isconcretetype(T) && haskey(schema_obj, "properties") - properties = schema_obj["properties"] +# Default fallback +_validate(::Any, ::Any, ::Val, ::Any, ::String) = nothing - style = StructUtils.DefaultStyle() - all_field_tags = StructUtils.fieldtags(style, T) +# JSON treats == between Bool and Number differently to Julia, so: +# false != 0 +# true != 1 +# 0 == 0.0 +# 1.0 == 1 +_isequal(x, y) = x == y - for i in 1:fieldcount(T) - fname = fieldname(T, i) - ftype = fieldtype(T, i) - fvalue = getfield(instance, fname) +_isequal(::Bool, ::Number) = false - tags = _json_field_tags(all_field_tags, fname) +_isequal(::Number, ::Bool) = false - # Skip ignored fields - if _field_ignored(tags) - continue - end +_isequal(x::Bool, y::Bool) = x == y - # Get JSON name (may be renamed) - json_name = _json_field_name(fname, tags) +function _isequal(x::AbstractVector, y::AbstractVector) + return length(x) == length(y) && all(_isequal.(x, y)) +end - # Check if field is in schema - if haskey(properties, json_name) - field_schema = properties[json_name] - field_path = isempty(path) ? json_name : "$path.$json_name" - # Use actual value type for validation, not field type (handles Union{T, Nothing} properly) - val_type = fvalue === nothing || fvalue === missing ? ftype : typeof(fvalue) - _validate_value(field_schema, fvalue, val_type, tags, field_path, errors, verbose, root) - end - end +function _isequal(x::AbstractDict, y::AbstractDict) + return Set(keys(x)) == Set(keys(y)) && + all(_isequal(v, y[k]) for (k, v) in x) +end - # Validate propertyNames - property names must match schema - if haskey(schema_obj, "propertyNames") - prop_names_schema = schema_obj["propertyNames"] - for i in 1:fieldcount(T) - fname = fieldname(T, i) - tags = _json_field_tags(all_field_tags, fname) - - # Skip ignored fields - if _field_ignored(tags) - continue - end - - # Get JSON name - json_name = _json_field_name(fname, tags) - - # Validate the property name itself as a string - prop_errors = String[] - _validate_value(prop_names_schema, json_name, String, nothing, path, prop_errors, false, root) - if !isempty(prop_errors) - push!(errors, "$path: property name '$json_name' is invalid") - end - end - end +### +### Core JSON Schema +### - # Validate dependencies - if property X exists, properties Y and Z must exist - if haskey(schema_obj, "dependencies") - dependencies = schema_obj["dependencies"] - for i in 1:fieldcount(T) - fname = fieldname(T, i) - fvalue = getfield(instance, fname) - tags = _json_field_tags(all_field_tags, fname) - - # Skip ignored fields - if _field_ignored(tags) - continue - end - - # Skip fields with nothing/missing values (treat as "not present") - if fvalue === nothing || fvalue === missing - continue - end - - # Get JSON name - json_name = _json_field_name(fname, tags) - - # If this property exists in dependencies - if haskey(dependencies, json_name) - dep = dependencies[json_name] - - # Dependencies can be an array of required properties - if dep isa Vector - for required_prop in dep - # Check if the required property exists in the struct and is not nothing/missing - found = false - for j in 1:fieldcount(T) - other_fname = fieldname(T, j) - other_fvalue = getfield(instance, j) - other_tags = _json_field_tags(all_field_tags, other_fname) - - if _field_ignored(other_tags) - continue - end - - other_json_name = _json_field_name(other_fname, other_tags) - - # Check if name matches and value is not nothing/missing - if other_json_name == required_prop && other_fvalue !== nothing && other_fvalue !== missing - found = true - break - end - end - - if !found - push!(errors, "$path: property '$json_name' requires property '$required_prop' to exist") - end - end - # Dependencies can also be a schema (schema-based dependency) - elseif dep isa Object - # If the property exists, validate the whole instance against the dependency schema - _validate_value(dep, instance, T, nothing, path, errors, verbose, root) - end - end - end +# 9.2.1.1 +function _validate(x, schema, ::Val{:allOf}, val::AbstractVector, path::String) + for v in val + ret = _validate(x, v, path) + if ret !== nothing + return ret end - - # Validate additionalProperties for structs - # Check if there are fields in the struct not defined in the schema - if haskey(schema_obj, "additionalProperties") - additional_allowed = schema_obj["additionalProperties"] - - # If additionalProperties is false, no extra properties allowed - if additional_allowed === false - for i in 1:fieldcount(T) - fname = fieldname(T, i) - tags = _json_field_tags(all_field_tags, fname) - - # Skip ignored fields - if _field_ignored(tags) - continue - end - - # Get JSON name - json_name = _json_field_name(fname, tags) - - # Check if this property is defined in the schema - if !haskey(properties, json_name) - push!(errors, "$path: additional property '$json_name' not allowed") - end - end - # If additionalProperties is a schema, validate extra properties against it - elseif additional_allowed isa Object - for i in 1:fieldcount(T) - fname = fieldname(T, i) - ftype = fieldtype(T, i) - fvalue = getfield(instance, fname) - tags = _json_field_tags(all_field_tags, fname) - - # Skip ignored fields - if _field_ignored(tags) - continue - end - - # Get JSON name - json_name = _json_field_name(fname, tags) - - # If this property is not in the schema, validate it against additionalProperties - if !haskey(properties, json_name) - field_path = isempty(path) ? json_name : "$path.$json_name" - val_type = fvalue === nothing || fvalue === missing ? ftype : typeof(fvalue) - _validate_value(additional_allowed, fvalue, val_type, tags, field_path, errors, verbose, root) - end - end - end - end - - return end - - # For non-struct types, validate directly - return _validate_value(schema_obj, instance, T, nothing, path, errors, verbose, root) + return end -# Internal: Validate a single value against schema constraints -function _validate_value(schema, value, ::Type{T}, tags, path::String, errors::Vector{String}, verbose::Bool, root::Object{String, Any}) where {T} - # Handle $ref - resolve and validate against resolved schema - if haskey(schema, "\$ref") - ref_path = schema["\$ref"] - try - resolved_schema = _resolve_ref(ref_path, root) - # Recursively validate with resolved schema - return _validate_value(resolved_schema, value, T, tags, path, errors, verbose, root) - catch e - push!(errors, "$path: error resolving \$ref: $(e.msg)") +# 9.2.1.2 +function _validate(x, schema, ::Val{:anyOf}, val::AbstractVector, path::String) + for v in val + if _validate(x, v, path) === nothing return end end + return SingleIssue(x, path, "anyOf", val) +end - # Handle Nothing/Missing - if value === nothing || value === missing - # Check if null is allowed - schema_type = get(schema, "type", nothing) - if schema_type isa Vector && !("null" in schema_type) - push!(errors, "$path: null value not allowed") - elseif schema_type isa String && schema_type != "null" - push!(errors, "$path: null value not allowed") +# 9.2.1.3 +function _validate(x, schema, ::Val{:oneOf}, val::AbstractVector, path::String) + found_match = false + for v in val + if _validate(x, v, path) === nothing + if found_match # Found more than one match! + return SingleIssue(x, path, "oneOf", val) + end + found_match = true end - return end - - # Validate type if specified in schema - if haskey(schema, "type") - _validate_type(schema["type"], value, path, errors) + if !found_match + return SingleIssue(x, path, "oneOf", val) end + return +end - # String validation - if value isa AbstractString - _validate_string(schema, tags, string(value), path, errors) +# 9.2.1.4 +function _validate(x, schema, ::Val{:not}, val, path::String) + if _validate(x, val, path) === nothing + return SingleIssue(x, path, "not", val) end + return +end - # Numeric validation - if value isa Number - _validate_number(schema, tags, value, path, errors) +# 9.2.2.1: if +function _validate(x, schema, ::Val{:if}, val, path::String) + # ignore if without then or else + if haskey(schema, "then") || haskey(schema, "else") + return _if_then_else(x, schema, path) end + return +end - # Array validation - if value isa AbstractVector - _validate_array(schema, tags, value, path, errors, verbose, root) +# 9.2.2.2: then +function _validate(x, schema, ::Val{:then}, val, path::String) + # ignore then without if + if haskey(schema, "if") + return _if_then_else(x, schema, path) end + return +end - # Tuple validation (treat as array for JSON Schema purposes) - if value isa Tuple - _validate_array(schema, tags, collect(value), path, errors, verbose, root) +# 9.2.2.3: else +function _validate(x, schema, ::Val{:else}, val, path::String) + # ignore else without if + if haskey(schema, "if") + return _if_then_else(x, schema, path) end + return +end - # Set validation - if value isa AbstractSet - _validate_array(schema, tags, collect(value), path, errors, verbose, root) - end +""" + _if_then_else(x, schema, path) - # Enum validation - if haskey(schema, "enum") - if !(value in schema["enum"]) - push!(errors, "$path: value must be one of $(schema["enum"]), got $(repr(value))") - end - end +The if, then and else keywords allow the application of a subschema based on the +outcome of another schema. Details are in the link and the truth table is as +follows: - # Const validation - if haskey(schema, "const") - if value != schema["const"] - push!(errors, "$path: value must be $(repr(schema["const"])), got $(repr(value))") - end - end +``` +┌─────┬──────┬──────┬────────┐ +│ if │ then │ else │ result │ +├─────┼──────┼──────┼────────┤ +│ T │ T │ n/a │ T │ +│ T │ F │ n/a │ F │ +│ F │ n/a │ T │ T │ +│ F │ n/a │ F │ F │ +│ n/a │ n/a │ n/a │ T │ +└─────┴──────┴──────┴────────┘ +``` - # Nested object validation - if haskey(schema, "properties") && isstructtype(T) && isconcretetype(T) - _validate_instance(schema, value, T, path, errors, verbose, root) +See https://json-schema.org/understanding-json-schema/reference/conditionals#ifthenelse +for details. +""" +function _if_then_else(x, schema, path) + if _validate(x, schema["if"], path) !== nothing + if haskey(schema, "else") + return _validate(x, schema["else"], path) + end + elseif haskey(schema, "then") + return _validate(x, schema["then"], path) end + return +end - # Dict/Object validation (properties, patternProperties, propertyNames for Dicts) - if value isa AbstractDict - # Validate properties for Dict - if haskey(schema, "properties") - properties = schema["properties"] - required = get(() -> String[], schema, "required") +### +### Checks for Arrays. +### + +# 9.3.1.1 +function _validate( + x::AbstractVector, + schema, + ::Val{:items}, + val::AbstractDict, + path::String, +) + items = fill(false, length(x)) + for (i, xi) in enumerate(x) + ret = _validate(xi, val, path * "[$(i)]") + if ret !== nothing + return ret + end + items[i] = true + end + additionalItems = get(schema, "additionalItems", nothing) + return _additional_items(x, schema, items, additionalItems, path) +end - # Validate each property - for (prop_name, prop_schema) in properties - if haskey(value, prop_name) || haskey(value, Symbol(prop_name)) - prop_value = haskey(value, prop_name) ? value[prop_name] : value[Symbol(prop_name)] - val_path = isempty(path) ? string(prop_name) : "$path.$(prop_name)" - _validate_value(prop_schema, prop_value, typeof(prop_value), nothing, val_path, errors, verbose, root) - elseif prop_name in required - push!(errors, "$path: required property '$prop_name' is missing") - end - end - end +function _validate( + x::AbstractVector, + schema, + ::Val{:items}, + val::AbstractVector, + path::String, +) + items = fill(false, length(x)) + for (i, xi) in enumerate(x) + if i > length(val) + break + end + ret = _validate(xi, val[i], path * "[$(i)]") + if ret !== nothing + return ret + end + items[i] = true + end + additionalItems = get(schema, "additionalItems", nothing) + return _additional_items(x, schema, items, additionalItems, path) +end - # Validate propertyNames for Dict - if haskey(schema, "propertyNames") - prop_names_schema = schema["propertyNames"] - for key in keys(value) - key_str = string(key) - prop_errors = String[] - _validate_value(prop_names_schema, key_str, String, nothing, path, prop_errors, false, root) - if !isempty(prop_errors) - push!(errors, "$path: property name '$key_str' is invalid") - end - end - end +function _validate( + x::AbstractVector, + schema, + ::Val{:items}, + val::Bool, + path::String, +) + if !val && length(x) > 0 + return SingleIssue(x, path, "items", val) + end + return +end - # Validate patternProperties for Dict - if haskey(schema, "patternProperties") - pattern_props = schema["patternProperties"] - for (pattern_str, prop_schema) in pattern_props - pattern_regex = Regex(pattern_str) - for (key, val) in value - key_str = string(key) - # If key matches the pattern, validate value against the schema - if occursin(pattern_regex, key_str) - val_path = isempty(path) ? key_str : "$path.$key_str" - _validate_value(prop_schema, val, typeof(val), nothing, val_path, errors, verbose, root) - end - end - end +function _additional_items(x, schema, items, val, path) + for i in 1:length(x) + if items[i] + continue # Validated against 'items'. end - - # Validate dependencies for Dict - if haskey(schema, "dependencies") - dependencies = schema["dependencies"] - for (prop_name, dep) in dependencies - # If the property exists in the dict - if haskey(value, prop_name) || haskey(value, Symbol(prop_name)) - # Dependencies can be an array of required properties - if dep isa Vector - for required_prop in dep - if !haskey(value, required_prop) && !haskey(value, Symbol(required_prop)) - push!(errors, "$path: property '$prop_name' requires property '$required_prop' to exist") - end - end - # Dependencies can also be a schema - elseif dep isa Object - _validate_value(dep, value, T, nothing, path, errors, verbose, root) - end - end - end + ret = _validate(x[i], val, path * "[$(i)]") + if ret !== nothing + return ret end end - - # Composition validation - return _validate_composition(schema, value, T, path, errors, verbose, root) + return end -# Validate composition keywords (oneOf, anyOf, allOf) -function _validate_composition(schema, value, ::Type{T}, path::String, errors::Vector{String}, verbose::Bool, root::Object{String, Any}) where {T} - # Use the actual value's type for validation - actual_type = typeof(value) - - # oneOf: exactly one schema must validate - if haskey(schema, "oneOf") - schemas = schema["oneOf"] - valid_count = 0 +function _additional_items(x, schema, items, val::Bool, path) + if !val && !all(items) + return SingleIssue(x, path, "additionalItems", val) + end + return +end - for sub_schema in schemas - sub_errors = String[] - _validate_value(sub_schema, value, actual_type, nothing, path, sub_errors, false, root) - if isempty(sub_errors) - valid_count += 1 - end - end +_additional_items(x, schema, items, val::Nothing, path) = nothing + +# 9.3.1.2 +function _validate( + x::AbstractVector, + schema, + ::Val{:additionalItems}, + val, + path::String, +) + return # Supported in `items`. +end - if valid_count == 0 - push!(errors, "$path: value does not match any oneOf schemas") - elseif valid_count > 1 - push!(errors, "$path: value matches multiple oneOf schemas (expected exactly one)") +# 9.3.1.3: unevaluatedProperties + +# 9.3.1.4 +function _validate( + x::AbstractVector, + schema, + ::Val{:contains}, + val, + path::String, +) + for (i, xi) in enumerate(x) + ret = _validate(xi, val, path * "[$(i)]") + if ret === nothing + return end end + return SingleIssue(x, path, "contains", val) +end - # anyOf: at least one schema must validate - if haskey(schema, "anyOf") - schemas = schema["anyOf"] - any_valid = false - - for sub_schema in schemas - sub_errors = String[] - _validate_value(sub_schema, value, actual_type, nothing, path, sub_errors, false, root) - if isempty(sub_errors) - any_valid = true - break +### +### Checks for Objects +### + +# 9.3.2.1 +function _validate( + x::AbstractDict, + schema, + ::Val{:properties}, + val::AbstractDict, + path::String, +) + for (k, v) in x + if haskey(val, k) + ret = _validate(v, val[k], path * "[$(k)]") + if ret !== nothing + return ret end end + end + return +end - if !any_valid - push!(errors, "$path: value does not match any anyOf schemas") +# 9.3.2.2 +function _validate( + x::AbstractDict, + schema, + ::Val{:patternProperties}, + val::AbstractDict, + path::String, +) + for (k_val, v_val) in val + r = Regex(k_val) + for (k_x, v_x) in x + if match(r, k_x) === nothing + continue + end + ret = _validate(v_x, v_val, path * "[$(k_x)") + if ret !== nothing + return ret + end end end + return +end - # allOf: all schemas must validate - if haskey(schema, "allOf") - schemas = schema["allOf"] +# 9.3.2.3 +function _validate( + x::AbstractDict, + schema, + ::Val{:additionalProperties}, + val::AbstractDict, + path::String, +) + properties = get(schema, "properties", Dict{String,Any}()) + patternProperties = get(schema, "patternProperties", Dict{String,Any}()) + for (k, v) in x + if k in keys(properties) || + any(r -> match(Regex(r), k) !== nothing, keys(patternProperties)) + continue + end + ret = _validate(v, val, path * "[$(k)]") + if ret !== nothing + return ret + end + end + return +end - for sub_schema in schemas - _validate_value(sub_schema, value, actual_type, nothing, path, errors, verbose, root) +function _validate( + x::AbstractDict, + schema, + ::Val{:additionalProperties}, + val::Bool, + path::String, +) + if val + return + end + properties = get(schema, "properties", Dict{String,Any}()) + patternProperties = get(schema, "patternProperties", Dict{String,Any}()) + for (k, v) in x + if k in keys(properties) || + any(r -> match(Regex(r), k) !== nothing, keys(patternProperties)) + continue end + return SingleIssue(x, path, "additionalProperties", val) end + return +end - # not: schema must NOT validate - if haskey(schema, "not") - not_schema = schema["not"] - sub_errors = String[] - _validate_value(not_schema, value, actual_type, nothing, path, sub_errors, false, root) +# 9.3.2.4: unevaluatedProperties - # If validation succeeds (no errors), it means the value DOES match the not schema, which is invalid - if isempty(sub_errors) - push!(errors, "$path: value must NOT match the specified schema") +# 9.3.2.5 +function _validate( + x::AbstractDict, + schema, + ::Val{:propertyNames}, + val, + path::String, +) + for k in keys(x) + ret = _validate(k, val, path) + if ret !== nothing + return ret end end + return +end - # Conditional validation: if/then/else - return if haskey(schema, "if") - if_schema = schema["if"] - sub_errors = String[] - _validate_value(if_schema, value, actual_type, nothing, path, sub_errors, false, root) +### +### Checks for generic types. +### - # If the "if" schema is valid, apply "then" schema (if present) - if isempty(sub_errors) - if haskey(schema, "then") - then_schema = schema["then"] - _validate_value(then_schema, value, actual_type, nothing, path, errors, verbose, root) - end - # If the "if" schema is invalid, apply "else" schema (if present) - else - if haskey(schema, "else") - else_schema = schema["else"] - _validate_value(else_schema, value, actual_type, nothing, path, errors, verbose, root) - end - end +# 6.1.1 +function _validate(x, schema, ::Val{:type}, val::String, path::String) + if !_is_type(x, Val{Symbol(val)}()) + return SingleIssue(x, path, "type", val) end + return end -# String validation -function _validate_string(schema, tags, value::String, path::String, errors::Vector{String}) - # Check minLength - min_len = get(schema, "minLength", nothing) - if min_len !== nothing && length(value) < min_len - push!(errors, "$path: string length $(length(value)) is less than minimum $min_len") +function _validate(x, schema, ::Val{:type}, val::AbstractVector, path::String) + if !any(v -> _is_type(x, Val{Symbol(v)}()), val) + return SingleIssue(x, path, "type", val) end + return +end - # Check maxLength - max_len = get(schema, "maxLength", nothing) - if max_len !== nothing && length(value) > max_len - push!(errors, "$path: string length $(length(value)) exceeds maximum $max_len") - end +_is_type(::Any, ::Val) = false +_is_type(::Array, ::Val{:array}) = true +_is_type(::Bool, ::Val{:boolean}) = true +_is_type(::Integer, ::Val{:integer}) = true +_is_type(x::Float64, ::Val{:integer}) = isinteger(x) +_is_type(::Real, ::Val{:number}) = true +_is_type(::Nothing, ::Val{:null}) = true +_is_type(::Missing, ::Val{:null}) = true +_is_type(::AbstractDict, ::Val{:object}) = true +_is_type(::String, ::Val{:string}) = true +# Note that Julia treat's Bool <: Number, but JSON-Schema distinguishes them. +_is_type(::Bool, ::Val{:number}) = false +_is_type(::Bool, ::Val{:integer}) = false + +# 6.1.2 +function _validate(x, schema, ::Val{:enum}, val, path::String) + if !any(_isequal(x, v) for v in val) + return SingleIssue(x, path, "enum", val) + end + return +end - # Check pattern - pattern = get(schema, "pattern", nothing) - if pattern !== nothing - try - regex = Regex(pattern) - if !occursin(regex, value) - push!(errors, "$path: string does not match pattern $pattern") - end - catch e - # Invalid regex pattern - skip validation - end +# 6.1.3 +function _validate(x, schema, ::Val{:const}, val, path::String) + if !_isequal(x, val) + return SingleIssue(x, path, "const", val) end + return +end - # Format validation (basic checks) - format = get(schema, "format", nothing) - return if format !== nothing - _validate_format(format, value, path, errors) - end +### +### Checks for numbers. +### + +# 6.2.1 +function _validate( + x::Number, + schema, + ::Val{:multipleOf}, + val::Number, + path::String, +) + y = x / val + if !isfinite(y) || !isapprox(y, round(y)) + return SingleIssue(x, path, "multipleOf", val) + end + return end -# Format validation -function _validate_format(format::String, value::String, path::String, errors::Vector{String}) - return if format == "email" - # RFC 5322 compatible regex (simplified but better than before) - # Disallows spaces, requires @ and domain part - if !occursin(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", value) - push!(errors, "$path: invalid email format") - end - elseif format == "uri" || format == "url" - # URI validation: Scheme required, no whitespace - # Matches "http://example.com", "ftp://file", "mailto:user@host", "urn:uuid:..." - if !occursin(r"^[a-zA-Z][a-zA-Z0-9+.-]*:[^\s]*$", value) - push!(errors, "$path: invalid URI format") - end - elseif format == "uuid" - # UUID validation - if !occursin(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"i, value) - push!(errors, "$path: invalid UUID format") - end - elseif format == "date-time" - # ISO 8601 date-time check (requires timezone) - # Matches: YYYY-MM-DDThh:mm:ss[.sss]Z or YYYY-MM-DDThh:mm:ss[.sss]+hh:mm - if !occursin(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[\+\-]\d{2}:?\d{2})$", value) - push!(errors, "$path: invalid date-time format (expected ISO 8601 with timezone)") - end - end - # Other formats could be added (ipv4, ipv6, etc.) +# 6.2.2 +function _validate( + x::Number, + schema, + ::Val{:maximum}, + val::Number, + path::String, +) + if x > val + return SingleIssue(x, path, "maximum", val) + end + return end -# Numeric validation -function _validate_number(schema, tags, value::Number, path::String, errors::Vector{String}) - # Check minimum - min_val = get(schema, "minimum", nothing) - exclusive_min = get(schema, "exclusiveMinimum", false) - if min_val !== nothing - if exclusive_min === true && value <= min_val - push!(errors, "$path: value $value must be greater than $min_val") - elseif exclusive_min === false && value < min_val - push!(errors, "$path: value $value is less than minimum $min_val") - end - end +# 6.2.3 +function _validate( + x::Number, + schema, + ::Val{:exclusiveMaximum}, + val::Number, + path::String, +) + if x >= val + return SingleIssue(x, path, "exclusiveMaximum", val) + end + return +end - # Check maximum - max_val = get(schema, "maximum", nothing) - exclusive_max = get(schema, "exclusiveMaximum", false) - if max_val !== nothing - if exclusive_max === true && value >= max_val - push!(errors, "$path: value $value must be less than $max_val") - elseif exclusive_max === false && value > max_val - push!(errors, "$path: value $value exceeds maximum $max_val") - end - end +function _validate( + x::Number, + schema, + ::Val{:exclusiveMaximum}, + val::Bool, + path::String, +) + if val && x >= get(schema, "maximum", Inf) + return SingleIssue(x, path, "exclusiveMaximum", val) + end + return +end - # Check multipleOf - multiple = get(schema, "multipleOf", nothing) - return if multiple !== nothing - # Check if value is a multiple of 'multiple' - if !isapprox(mod(value, multiple), 0.0, atol = 1.0e-10) && !isapprox(mod(value, multiple), multiple, atol = 1.0e-10) - push!(errors, "$path: value $value is not a multiple of $multiple") - end - end +# 6.2.4 +function _validate( + x::Number, + schema, + ::Val{:minimum}, + val::Number, + path::String, +) + if x < val + return SingleIssue(x, path, "minimum", val) + end + return end -# Array validation -function _validate_array(schema, tags, value::AbstractVector, path::String, errors::Vector{String}, verbose::Bool, root::Object{String, Any}) - # Check minItems - min_items = get(schema, "minItems", nothing) - if min_items !== nothing && length(value) < min_items - push!(errors, "$path: array length $(length(value)) is less than minimum $min_items") - end +# 6.2.5 +function _validate( + x::Number, + schema, + ::Val{:exclusiveMinimum}, + val::Number, + path::String, +) + if x <= val + return SingleIssue(x, path, "exclusiveMinimum", val) + end + return +end - # Check maxItems - max_items = get(schema, "maxItems", nothing) - if max_items !== nothing && length(value) > max_items - push!(errors, "$path: array length $(length(value)) exceeds maximum $max_items") - end +function _validate( + x::Number, + schema, + ::Val{:exclusiveMinimum}, + val::Bool, + path::String, +) + if val && x <= get(schema, "minimum", -Inf) + return SingleIssue(x, path, "exclusiveMinimum", val) + end + return +end - # Check uniqueItems - unique_items = get(schema, "uniqueItems", false) - if unique_items && length(value) != length(unique(value)) - push!(errors, "$path: array items must be unique") - end +### +### Checks for strings. +### + +# 6.3.1 +function _validate( + x::String, + schema, + ::Val{:maxLength}, + val::Union{Integer,Float64}, + path::String, +) + if length(x) > val + return SingleIssue(x, path, "maxLength", val) + end + return +end - # Check contains: at least one item must match the contains schema - if haskey(schema, "contains") - contains_schema = schema["contains"] - any_match = false +# 6.3.2 +function _validate( + x::String, + schema, + ::Val{:minLength}, + val::Union{Integer,Float64}, + path::String, +) + if length(x) < val + return SingleIssue(x, path, "minLength", val) + end + return +end - for item in value - sub_errors = String[] - item_type = typeof(item) - _validate_value(contains_schema, item, item_type, nothing, path, sub_errors, false, root) - if isempty(sub_errors) - any_match = true - break - end - end +# 6.3.3 +function _validate( + x::String, + schema, + ::Val{:pattern}, + val::String, + path::String, +) + if !occursin(Regex(val), x) + return SingleIssue(x, path, "pattern", val) + end + return +end - if !any_match - push!(errors, "$path: array must contain at least one item matching the specified schema") - end - end +### +### Checks for arrays. +### + +# 6.4.1 +function _validate( + x::AbstractVector, + schema, + ::Val{:maxItems}, + val::Union{Integer,Float64}, + path::String, +) + if length(x) > val + return SingleIssue(x, path, "maxItems", val) + end + return +end - # Validate each item if items schema is present - return if haskey(schema, "items") - items_schema = schema["items"] - - # Check if items is an array (tuple validation) or a single schema - if items_schema isa AbstractVector - # Tuple validation: each position has its own schema - for (i, item) in enumerate(value) - item_path = "$path[$(i - 1)]" # 0-indexed for JSON - item_type = typeof(item) - - # Use the corresponding schema if available - if i <= length(items_schema) - _validate_value(items_schema[i], item, item_type, nothing, item_path, errors, verbose, root) - # For items beyond the tuple schemas, check additionalItems - else - if haskey(schema, "additionalItems") - additional_items_schema = schema["additionalItems"] - # If additionalItems is false, extra items are not allowed - if additional_items_schema === false - push!(errors, "$path: additional items not allowed at index $(i - 1)") - # If additionalItems is a schema, validate against it - elseif additional_items_schema isa Object - _validate_value(additional_items_schema, item, item_type, nothing, item_path, errors, verbose, root) - end - end - end - end - else - # Single schema: applies to all items - for (i, item) in enumerate(value) - item_path = "$path[$(i - 1)]" # 0-indexed for JSON - item_type = typeof(item) - _validate_value(items_schema, item, item_type, nothing, item_path, errors, verbose, root) - end +# 6.4.2 +function _validate( + x::AbstractVector, + schema, + ::Val{:minItems}, + val::Union{Integer,Float64}, + path::String, +) + if length(x) < val + return SingleIssue(x, path, "minItems", val) + end + return +end + +# 6.4.3 +function _validate( + x::AbstractVector, + schema, + ::Val{:uniqueItems}, + val::Bool, + path::String, +) + if !val + return + end + # TODO(odow): O(n^2) here. But probably not too bad, because there shouldn't + # be a large x. + for i in eachindex(x), j in eachindex(x) + if i != j && _isequal(x[i], x[j]) + return SingleIssue(x, path, "uniqueItems", val) end end + return end -# Validate JSON Schema type -function _validate_type(schema_type, value, path::String, errors::Vector{String}) - # Handle array of types (e.g., ["string", "null"]) - return if schema_type isa Vector - type_matches = false - for t in schema_type - if _matches_type(t, value) - type_matches = true - break - end - end - if !type_matches - push!(errors, "$path: value type $(typeof(value)) does not match any of $schema_type") - end - elseif schema_type isa String - if !_matches_type(schema_type, value) - push!(errors, "$path: value type $(typeof(value)) does not match expected type $schema_type") - end +# 6.4.4: maxContains + +# 6.4.5: minContains + +### +### Checks for objects. +### + +# 6.5.1 +function _validate( + x::AbstractDict, + schema, + ::Val{:maxProperties}, + val::Union{Integer,Float64}, + path::String, +) + if length(x) > val + return SingleIssue(x, path, "maxProperties", val) end + return +end + +# 6.5.2 +function _validate( + x::AbstractDict, + schema, + ::Val{:minProperties}, + val::Union{Integer,Float64}, + path::String, +) + if length(x) < val + return SingleIssue(x, path, "minProperties", val) + end + return +end + +# 6.5.3 +function _validate( + x::AbstractDict, + schema, + ::Val{:required}, + val::AbstractVector, + path::String, +) + if any(v -> !haskey(x, v), val) + return SingleIssue(x, path, "required", val) + end + return +end + +# 6.5.4 +function _validate( + x::AbstractDict, + schema, + ::Val{:dependencies}, + val::AbstractDict, + path::String, +) + for (k, v) in val + if !haskey(x, k) + continue + elseif !_dependencies(x, path, v) + return SingleIssue(x, path, "dependencies", val) + end + end + return +end + +function _dependencies( + x::AbstractDict, + path::String, + val::Union{Bool,AbstractDict}, +) + return _validate(x, val, path) === nothing end -# Check if a value matches a JSON Schema type -function _matches_type(json_type::String, value) - if json_type == "null" - return value === nothing || value === missing - elseif json_type == "boolean" - return value isa Bool - elseif json_type == "integer" - # Explicitly exclude Bool since Bool <: Integer in Julia - return value isa Integer && !(value isa Bool) - elseif json_type == "number" - # Explicitly exclude Bool since Bool <: Number in Julia - return value isa Number && !(value isa Bool) - elseif json_type == "string" - return value isa AbstractString - elseif json_type == "array" - return value isa AbstractVector || value isa AbstractSet || value isa Tuple - elseif json_type == "object" - return value isa AbstractDict || (isstructtype(typeof(value)) && isconcretetype(typeof(value))) - end - return false +function _dependencies(x::AbstractDict, path::String, val::Array) + return all(v -> haskey(x, v), val) end diff --git a/test/JSONSchemaTestSuite.tar b/test/JSONSchemaTestSuite.tar deleted file mode 100644 index 30e813c..0000000 Binary files a/test/JSONSchemaTestSuite.tar and /dev/null differ diff --git a/test/Project.toml b/test/Project.toml index 0e74e24..e87aca0 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,11 +1,16 @@ [deps] -Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6" +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -StructUtils = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" -Tar = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e" +JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +ZipFile = "a5390f91-8eb1-5f08-bee0-b1d1ffed6cea" [compat] +HTTP = "1" JSON = "1" -StructUtils = "2" -julia = "1.10" +JSON3 = "1" +OrderedCollections = "1" +ZipFile = "0.8, 0.9, 0.10" +julia = "1.9" diff --git a/test/generation.jl b/test/generation.jl deleted file mode 100644 index 0af9b2a..0000000 --- a/test/generation.jl +++ /dev/null @@ -1,2049 +0,0 @@ -# Copyright (c) 2018-2026: fredo-dedup, quinnj, and contributors -# -# Use of this source code is governed by an MIT-style license that can be found -# in the LICENSE.md file or at https://opensource.org/licenses/MIT. - -using Test, JSON, JSONSchema, Dates, StructUtils - -@testset "JSON Schema Generation" begin - @testset "Primitive Types" begin - # Integer - @defaults struct SimpleInt - value::Int = 0 - end - schema = JSONSchema.schema(SimpleInt) - @test schema["\$schema"] == "https://json-schema.org/draft-07/schema#" - @test schema["type"] == "object" - @test schema["properties"]["value"]["type"] == "integer" - @test schema["required"] == ["value"] - - # Float - @defaults struct SimpleFloat - value::Float64 = 0.0 - end - schema = JSONSchema.schema(SimpleFloat) - @test schema["properties"]["value"]["type"] == "number" - - # String - @defaults struct SimpleString - value::String = "" - end - schema = JSONSchema.schema(SimpleString) - @test schema["properties"]["value"]["type"] == "string" - - # Boolean - @defaults struct SimpleBool - value::Bool = false - end - schema = JSONSchema.schema(SimpleBool) - @test schema["properties"]["value"]["type"] == "boolean" - end - - @testset "Optional Fields (Union{T, Nothing})" begin - @defaults struct OptionalFields - required_field::String = "" - optional_field::Union{String, Nothing} = nothing - another_optional::Union{Int, Nothing} = nothing - end - - schema = JSONSchema.schema(OptionalFields) - @test "required_field" in schema["required"] - @test !("optional_field" in schema["required"]) - @test !("another_optional" in schema["required"]) - - # Optional field should allow null type - @test schema["properties"]["optional_field"]["type"] == ["string", "null"] - @test schema["properties"]["another_optional"]["type"] == ["integer", "null"] - end - - @testset "String Validation Tags" begin - @defaults struct StringValidation - email::String = "" & ( - json = ( - description = "Email address", - format = "email", - minLength = 5, - maxLength = 100, - ), - ) - username::String = "" & ( - json = ( - pattern = "^[a-zA-Z0-9_]+\$", - minLength = 3, - maxLength = 20, - ), - ) - website::Union{String, Nothing} = nothing & ( - json = ( - format = "uri", - description = "Personal website URL", - ), - ) - end - - schema = JSONSchema.schema(StringValidation) - - # Email field - @test schema["properties"]["email"]["type"] == "string" - @test schema["properties"]["email"]["format"] == "email" - @test schema["properties"]["email"]["minLength"] == 5 - @test schema["properties"]["email"]["maxLength"] == 100 - @test schema["properties"]["email"]["description"] == "Email address" - - # Username field - @test schema["properties"]["username"]["pattern"] == "^[a-zA-Z0-9_]+\$" - @test schema["properties"]["username"]["minLength"] == 3 - @test schema["properties"]["username"]["maxLength"] == 20 - - # Website field (optional) - @test schema["properties"]["website"]["format"] == "uri" - @test !("website" in schema["required"]) - end - - @testset "Numeric Validation Tags" begin - @defaults struct NumericValidation - age::Int = 0 & ( - json = ( - minimum = 0, - maximum = 150, - description = "Age in years", - ), - ) - price::Float64 = 0.0 & ( - json = ( - minimum = 0.0, - exclusiveMinimum = true, - description = "Price must be positive", - ), - ) - percentage::Float64 = 0.0 & ( - json = ( - minimum = 0.0, - maximum = 100.0, - multipleOf = 0.1, - ), - ) - end - - schema = JSONSchema.schema(NumericValidation) - - # Age - @test schema["properties"]["age"]["minimum"] == 0 - @test schema["properties"]["age"]["maximum"] == 150 - - # Price - @test schema["properties"]["price"]["minimum"] == 0.0 - @test schema["properties"]["price"]["exclusiveMinimum"] == true - - # Percentage - @test schema["properties"]["percentage"]["multipleOf"] == 0.1 - end - - @testset "Array Types" begin - @defaults struct ArrayTypes - tags::Vector{String} = String[] - numbers::Vector{Int} = Int[] - matrix::Vector{Vector{Float64}} = Vector{Vector{Float64}}() - end - - schema = JSONSchema.schema(ArrayTypes) - - # Tags - @test schema["properties"]["tags"]["type"] == "array" - @test schema["properties"]["tags"]["items"]["type"] == "string" - - # Numbers - @test schema["properties"]["numbers"]["type"] == "array" - @test schema["properties"]["numbers"]["items"]["type"] == "integer" - - # Matrix (nested arrays) - @test schema["properties"]["matrix"]["type"] == "array" - @test schema["properties"]["matrix"]["items"]["type"] == "array" - @test schema["properties"]["matrix"]["items"]["items"]["type"] == "number" - end - - @testset "Array Validation Tags" begin - @defaults struct ArrayValidation - tags::Vector{String} = String[] & ( - json = ( - minItems = 1, - maxItems = 10, - description = "List of tags", - ), - ) - unique_ids::Vector{Int} = Int[] & ( - json = ( - uniqueItems = true, - minItems = 1, - ), - ) - end - - schema = JSONSchema.schema(ArrayValidation) - - @test schema["properties"]["tags"]["minItems"] == 1 - @test schema["properties"]["tags"]["maxItems"] == 10 - @test schema["properties"]["unique_ids"]["uniqueItems"] == true - end - - @testset "Nested Structs" begin - @defaults struct Address - street::String = "" - city::String = "" - zipcode::String = "" & (json = (pattern = "^[0-9]{5}\$",),) - end - - @defaults struct Person - name::String = "" - age::Int = 0 - address::Address = Address() - end - - schema = JSONSchema.schema(Person) - - @test schema["properties"]["address"]["type"] == "object" - @test haskey(schema["properties"]["address"], "properties") - @test schema["properties"]["address"]["properties"]["street"]["type"] == "string" - @test schema["properties"]["address"]["properties"]["city"]["type"] == "string" - @test schema["properties"]["address"]["properties"]["zipcode"]["pattern"] == "^[0-9]{5}\$" - end - - @testset "Field Renaming" begin - @defaults struct RenamedFields - internal_id::Int = 0 & (json = (name = "id",),) - first_name::String = "" & (json = (name = "firstName",),) - last_name::String = "" & (json = (name = "lastName",),) - end - - schema = JSONSchema.schema(RenamedFields) - - @test haskey(schema["properties"], "id") - @test haskey(schema["properties"], "firstName") - @test haskey(schema["properties"], "lastName") - @test !haskey(schema["properties"], "internal_id") - @test !haskey(schema["properties"], "first_name") - @test !haskey(schema["properties"], "last_name") - end - - @testset "Ignored Fields" begin - @defaults struct WithIgnored - public_field::String = "" - private_field::String = "" & (json = (ignore = true,),) - another_public::Int = 0 - end - - schema = JSONSchema.schema(WithIgnored) - - @test haskey(schema["properties"], "public_field") - @test haskey(schema["properties"], "another_public") - @test !haskey(schema["properties"], "private_field") - @test length(schema["properties"]) == 2 - end - - @testset "Enum and Const" begin - @defaults struct WithEnum - status::String = "pending" & ( - json = ( - enum = ["pending", "active", "inactive"], - description = "Account status", - ), - ) - api_version::String = "v1" & ( - json = ( - _const = "v1", - description = "API version (fixed)", - ), - ) - end - - schema = JSONSchema.schema(WithEnum) - - @test schema["properties"]["status"]["enum"] == ["pending", "active", "inactive"] - @test schema["properties"]["api_version"]["const"] == "v1" - end - - @testset "Examples and Default" begin - @defaults struct WithExamples - color::String = "blue" & ( - json = ( - examples = ["red", "green", "blue"], - description = "Favorite color", - ), - ) - count::Int = 10 & ( - json = ( - default = 10, - description = "Default count", - ), - ) - end - - schema = JSONSchema.schema(WithExamples) - - @test schema["properties"]["color"]["examples"] == ["red", "green", "blue"] - @test schema["properties"]["count"]["default"] == 10 - end - - @testset "Dict and Set Types" begin - @defaults struct CollectionTypes - metadata::Dict{String, Any} = Dict{String, Any}() - string_map::Dict{String, String} = Dict{String, String}() - unique_tags::Set{String} = Set{String}() - end - - schema = JSONSchema.schema(CollectionTypes) - - # Dict with Any values - @test schema["properties"]["metadata"]["type"] == "object" - @test haskey(schema["properties"]["metadata"], "additionalProperties") - - # Dict with String values - @test schema["properties"]["string_map"]["type"] == "object" - @test schema["properties"]["string_map"]["additionalProperties"]["type"] == "string" - - # Set - @test schema["properties"]["unique_tags"]["type"] == "array" - @test schema["properties"]["unique_tags"]["uniqueItems"] == true - @test schema["properties"]["unique_tags"]["items"]["type"] == "string" - end - - @testset "Tuple Types" begin - @defaults struct WithTuple - coordinates::Tuple{Float64, Float64} = (0.0, 0.0) - rgb::Tuple{Int, Int, Int} = (0, 0, 0) - end - - schema = JSONSchema.schema(WithTuple) - - # Coordinates (2-tuple of floats) - @test schema["properties"]["coordinates"]["type"] == "array" - @test schema["properties"]["coordinates"]["minItems"] == 2 - @test schema["properties"]["coordinates"]["maxItems"] == 2 - @test length(schema["properties"]["coordinates"]["items"]) == 2 - @test all(item["type"] == "number" for item in schema["properties"]["coordinates"]["items"]) - - # RGB (3-tuple of ints) - @test schema["properties"]["rgb"]["minItems"] == 3 - @test schema["properties"]["rgb"]["maxItems"] == 3 - @test all(item["type"] == "integer" for item in schema["properties"]["rgb"]["items"]) - end - - @testset "Complex Union Types" begin - @defaults struct ComplexUnion - value::Union{Int, String, Nothing} = nothing - end - - schema = JSONSchema.schema(ComplexUnion) - - # Should use oneOf for complex unions (Julia Union means exactly one type) - @test haskey(schema["properties"]["value"], "oneOf") - @test length(schema["properties"]["value"]["oneOf"]) == 3 - end - - @testset "Explicit Required Override" begin - @defaults struct RequiredOverride - # Explicitly mark as required even though it's Union{T, Nothing} - must_provide::Union{String, Nothing} = nothing & (json = (required = true,),) - # Explicitly mark as optional even though it's not a union - can_skip::String = "" & (json = (required = false,),) - end - - schema = JSONSchema.schema(RequiredOverride) - - @test "must_provide" in schema["required"] - @test !("can_skip" in schema["required"]) - end - - @testset "Top-level Schema Options" begin - @defaults struct MyType - value::Int = 0 - end - - schema = JSONSchema.schema( - MyType, - title = "Custom Title", - description = "Custom description for the schema", - id = "https://example.com/schemas/my-type.json" - ) - - @test schema["title"] == "Custom Title" - @test schema["description"] == "Custom description for the schema" - @test schema["\$id"] == "https://example.com/schemas/my-type.json" - end - - @testset "Schema Type" begin - @defaults struct SchemaTypeTest - value::Int = 0 - end - - schema = JSONSchema.schema(SchemaTypeTest) - - # Test that we get a Schema{T} object - @test schema isa JSONSchema.Schema{SchemaTypeTest} - @test schema.type === SchemaTypeTest - - # Test that we can access properties via indexing - @test schema["type"] == "object" - @test haskey(schema, "properties") - - # Test JSON serialization - json_str = JSON.json(schema) - @test occursin("object", json_str) - @test occursin("value", json_str) - end - - @testset "Comprehensive Example - User Registration" begin - @defaults struct UserRegistration - # Required fields with validation - username::String = "" & ( - json = ( - description = "Unique username for the account", - pattern = "^[a-zA-Z0-9_]{3,20}\$", - minLength = 3, - maxLength = 20, - ), - ) - - email::String = "" & ( - json = ( - description = "User's email address", - format = "email", - maxLength = 255, - ), - ) - - password::String = "" & ( - json = ( - description = "Account password", - minLength = 8, - maxLength = 128, - pattern = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*\$", - ), - ) - - age::Int = 0 & ( - json = ( - description = "User's age", - minimum = 13, - maximum = 150, - ), - ) - - # Optional fields - phone::Union{String, Nothing} = nothing & ( - json = ( - description = "Phone number", - pattern = "^\\+?[1-9]\\d{1,14}\$", - ), - ) - - website::Union{String, Nothing} = nothing & ( - json = ( - description = "Personal website", - format = "uri", - ), - ) - - # Array with validation - interests::Vector{String} = String[] & ( - json = ( - description = "List of interests", - minItems = 1, - maxItems = 10, - uniqueItems = true, - ), - ) - - # Enum field - account_type::String = "free" & ( - json = ( - description = "Type of account", - enum = ["free", "premium", "enterprise"], - default = "free", - ), - ) - - # Boolean field - newsletter::Bool = false & ( - json = ( - description = "Subscribe to newsletter", - default = false, - ), - ) - end - - schema = JSONSchema.schema( - UserRegistration, - title = "User Registration Schema", - description = "Schema for user registration endpoint", - id = "https://api.example.com/schemas/user-registration.json" - ) - - # Verify structure - @test schema["\$schema"] == "https://json-schema.org/draft-07/schema#" - @test schema["\$id"] == "https://api.example.com/schemas/user-registration.json" - @test schema["title"] == "User Registration Schema" - @test schema["type"] == "object" - - # Verify required fields - @test "username" in schema["required"] - @test "email" in schema["required"] - @test "password" in schema["required"] - @test "age" in schema["required"] - @test !("phone" in schema["required"]) - @test !("website" in schema["required"]) - - # Verify username constraints - @test schema["properties"]["username"]["minLength"] == 3 - @test schema["properties"]["username"]["maxLength"] == 20 - @test schema["properties"]["username"]["pattern"] == "^[a-zA-Z0-9_]{3,20}\$" - - # Verify email format - @test schema["properties"]["email"]["format"] == "email" - - # Verify password validation - @test schema["properties"]["password"]["minLength"] == 8 - - # Verify age range - @test schema["properties"]["age"]["minimum"] == 13 - @test schema["properties"]["age"]["maximum"] == 150 - - # Verify interests array - @test schema["properties"]["interests"]["type"] == "array" - @test schema["properties"]["interests"]["minItems"] == 1 - @test schema["properties"]["interests"]["maxItems"] == 10 - @test schema["properties"]["interests"]["uniqueItems"] == true - - # Verify enum - @test schema["properties"]["account_type"]["enum"] == ["free", "premium", "enterprise"] - - # Output the schema as JSON for inspection - json_output = JSON.json(schema, pretty = true) - @test occursin("User Registration Schema", json_output) - @test occursin("email", json_output) - end - - @testset "Nested Complex Example - E-commerce Product" begin - @defaults struct Price - amount::Float64 = 0.0 & ( - json = ( - description = "Price amount", - minimum = 0.0, - exclusiveMinimum = true, - ), - ) - currency::String = "USD" & ( - json = ( - description = "Currency code", - pattern = "^[A-Z]{3}\$", - default = "USD", - ), - ) - end - - @defaults struct Dimensions - length::Float64 = 0.0 & (json = (minimum = 0.0,),) - width::Float64 = 0.0 & (json = (minimum = 0.0,),) - height::Float64 = 0.0 & (json = (minimum = 0.0,),) - unit::String = "cm" & (json = (enum = ["cm", "in", "m"],),) - end - - @defaults struct Product - id::String = "" & ( - json = ( - description = "Unique product identifier", - format = "uuid", - ), - ) - name::String = "" & ( - json = ( - description = "Product name", - minLength = 1, - maxLength = 200, - ), - ) - description::String = "" & ( - json = ( - description = "Product description", - maxLength = 2000, - ), - ) - price::Price = Price() - dimensions::Union{Dimensions, Nothing} = nothing & ( - json = ( - description = "Product dimensions (optional)" - ), - ) - tags::Vector{String} = String[] & ( - json = ( - description = "Product tags", - uniqueItems = true, - maxItems = 20, - ), - ) - in_stock::Bool = true & ( - json = ( - description = "Whether the product is in stock" - ), - ) - quantity::Int = 0 & ( - json = ( - description = "Available quantity", - minimum = 0, - ), - ) - end - - schema = JSONSchema.schema(Product, title = "Product Schema") - - # Verify nested Price object - @test schema["properties"]["price"]["type"] == "object" - @test schema["properties"]["price"]["properties"]["amount"]["minimum"] == 0.0 - @test schema["properties"]["price"]["properties"]["amount"]["exclusiveMinimum"] == true - @test schema["properties"]["price"]["properties"]["currency"]["pattern"] == "^[A-Z]{3}\$" - - # Verify optional Dimensions - @test schema["properties"]["dimensions"]["type"] == ["object", "null"] - @test !("dimensions" in schema["required"]) - - # Test full JSON serialization - json_output = JSON.json(schema, pretty = true) - @test occursin("Product Schema", json_output) - @test occursin("uuid", json_output) - @test occursin("currency", json_output) - end - - @testset "Schema Validation - Roundtrip" begin - # Generate schema, serialize to JSON, parse back - @defaults struct SimpleType - id::Int = 0 - name::String = "" - end - - schema = JSONSchema.schema(SimpleType) - json_str = JSON.json(schema) - parsed = JSON.parse(json_str) - - @test parsed["type"] == "object" - @test haskey(parsed, "properties") - @test parsed["properties"]["id"]["type"] == "integer" - @test parsed["properties"]["name"]["type"] == "string" - end - - @testset "Empty Struct" begin - struct EmptyStruct end - - schema = JSONSchema.schema(EmptyStruct) - @test schema["type"] == "object" - @test haskey(schema, "properties") - @test length(schema["properties"]) == 0 - @test !haskey(schema, "required") || length(schema["required"]) == 0 - end - - @testset "Empty NamedTuple" begin - schema = JSONSchema.schema(@NamedTuple{}; all_fields_required = true, additionalProperties = false) - @test schema["type"] == "object" - @test haskey(schema, "properties") - @test length(schema["properties"]) == 0 - @test schema["additionalProperties"] == false - @test !haskey(schema, "required") || length(schema["required"]) == 0 - end - - @testset "Title and Description from Tags" begin - @defaults struct WithTitleDesc - value::Int = 0 & ( - json = ( - title = "Value Field", - description = "An important value", - ), - ) - end - - schema = JSONSchema.schema(WithTitleDesc) - @test schema["properties"]["value"]["title"] == "Value Field" - @test schema["properties"]["value"]["description"] == "An important value" - end - - @testset "Validation - String Constraints" begin - @defaults struct StringValidated - name::String = "" & (json = (minLength = 3, maxLength = 10),) - email::String = "" & (json = (format = "email",),) - username::String = "" & (json = (pattern = "^[a-z]+\$",),) - end - - schema = JSONSchema.schema(StringValidated) - - # Valid instances - @test JSONSchema.isvalid(schema, StringValidated("abc", "test@example.com", "hello")) - @test JSONSchema.isvalid(schema, StringValidated("abcdefghij", "a@b.c", "abc")) - - # Invalid: name too short - @test !JSONSchema.isvalid(schema, StringValidated("ab", "test@example.com", "hello")) - - # Invalid: name too long - @test !JSONSchema.isvalid(schema, StringValidated("abcdefghijk", "test@example.com", "hello")) - - # Invalid: bad email - @test !JSONSchema.isvalid(schema, StringValidated("abc", "not-an-email", "hello")) - - # Invalid: pattern mismatch (contains uppercase) - @test !JSONSchema.isvalid(schema, StringValidated("abc", "test@example.com", "Hello")) - end - - @testset "Validation - Numeric Constraints" begin - @defaults struct NumericValidated - age::Int = 0 & (json = (minimum = 0, maximum = 150),) - price::Float64 = 0.0 & (json = (minimum = 0.0, exclusiveMinimum = true),) - percentage::Float64 = 0.0 & (json = (multipleOf = 0.5,),) - end - - schema = JSONSchema.schema(NumericValidated) - - # Valid instances - @test JSONSchema.isvalid(schema, NumericValidated(25, 10.0, 5.0)) - @test JSONSchema.isvalid(schema, NumericValidated(0, 0.1, 0.5)) - - # Invalid: age too high - @test !JSONSchema.isvalid(schema, NumericValidated(200, 10.0, 5.0)) - - # Invalid: age negative - @test !JSONSchema.isvalid(schema, NumericValidated(-5, 10.0, 5.0)) - - # Invalid: price must be > 0 (exclusive) - @test !JSONSchema.isvalid(schema, NumericValidated(25, 0.0, 5.0)) - - # Invalid: not a multiple of 0.5 - @test !JSONSchema.isvalid(schema, NumericValidated(25, 10.0, 5.3)) - end - - @testset "Validation - Array Constraints" begin - @defaults struct ArrayValidated - tags::Vector{String} = String[] & (json = (minItems = 1, maxItems = 5, uniqueItems = true),) - numbers::Vector{Int} = Int[] & (json = (minItems = 2,),) - end - - schema = JSONSchema.schema(ArrayValidated) - - # Valid instances - @test JSONSchema.isvalid(schema, ArrayValidated(["a", "b"], [1, 2])) - @test JSONSchema.isvalid(schema, ArrayValidated(["a"], [1, 2, 3])) - - # Invalid: tags empty (minItems=1) - @test !JSONSchema.isvalid(schema, ArrayValidated(String[], [1, 2])) - - # Invalid: tags too many (maxItems=5) - @test !JSONSchema.isvalid(schema, ArrayValidated(["a", "b", "c", "d", "e", "f"], [1, 2])) - - # Invalid: tags not unique - @test !JSONSchema.isvalid(schema, ArrayValidated(["a", "a"], [1, 2])) - - # Invalid: numbers too few (minItems=2) - @test !JSONSchema.isvalid(schema, ArrayValidated(["a"], [1])) - end - - @testset "Validation - Enum and Const" begin - @defaults struct EnumValidated - status::String = "active" & (json = (enum = ["active", "inactive", "pending"],),) - version::String = "v1" & (json = (_const = "v1",),) - end - - schema = JSONSchema.schema(EnumValidated) - - # Valid instances - @test JSONSchema.isvalid(schema, EnumValidated("active", "v1")) - @test JSONSchema.isvalid(schema, EnumValidated("inactive", "v1")) - @test JSONSchema.isvalid(schema, EnumValidated("pending", "v1")) - - # Invalid: status not in enum - @test !JSONSchema.isvalid(schema, EnumValidated("deleted", "v1")) - - # Invalid: version doesn't match const - @test !JSONSchema.isvalid(schema, EnumValidated("active", "v2")) - end - - @testset "Validation - Optional Fields" begin - @defaults struct OptionalValidated - required_field::String = "" & (json = (minLength = 1,),) - optional_field::Union{String, Nothing} = nothing & (json = (minLength = 5,),) - end - - schema = JSONSchema.schema(OptionalValidated) - - # Valid: required field present, optional omitted - @test JSONSchema.isvalid(schema, OptionalValidated("test", nothing)) - - # Valid: both fields present and valid - @test JSONSchema.isvalid(schema, OptionalValidated("test", "hello")) - - # Invalid: required field empty - @test !JSONSchema.isvalid(schema, OptionalValidated("", nothing)) - - # Invalid: optional field present but too short - @test !JSONSchema.isvalid(schema, OptionalValidated("test", "hi")) - end - - @testset "Validation - Nested Structs" begin - @defaults struct InnerValidated - value::Int = 0 & (json = (minimum = 1, maximum = 10),) - end - - @defaults struct OuterValidated - name::String = "" & (json = (minLength = 1,),) - inner::InnerValidated = InnerValidated() - end - - schema = JSONSchema.schema(OuterValidated) - - # Valid instance - @test JSONSchema.isvalid(schema, OuterValidated("test", InnerValidated(5))) - - # Invalid: outer field fails - @test !JSONSchema.isvalid(schema, OuterValidated("", InnerValidated(5))) - - # Invalid: inner field fails - @test !JSONSchema.isvalid(schema, OuterValidated("test", InnerValidated(0))) - @test !JSONSchema.isvalid(schema, OuterValidated("test", InnerValidated(11))) - end - - @testset "Validation - Format Checks" begin - @defaults struct FormatValidated - email::String = "" & (json = (format = "email",),) - website::String = "" & (json = (format = "uri",),) - uuid::String = "" & (json = (format = "uuid",),) - timestamp::String = "" & (json = (format = "date-time",),) - end - - schema = JSONSchema.schema(FormatValidated) - - # Valid instance - @test JSONSchema.isvalid( - schema, FormatValidated( - "user@example.com", - "https://example.com", - "550e8400-e29b-41d4-a716-446655440000", - "2023-01-01T12:00:00Z" - ) - ) - - # Invalid: bad email - @test !JSONSchema.isvalid( - schema, FormatValidated( - "not-an-email", - "https://example.com", - "550e8400-e29b-41d4-a716-446655440000", - "2023-01-01T12:00:00Z" - ) - ) - - # Invalid: bad URI - @test !JSONSchema.isvalid( - schema, FormatValidated( - "user@example.com", - "not-a-uri", - "550e8400-e29b-41d4-a716-446655440000", - "2023-01-01T12:00:00Z" - ) - ) - - # Invalid: bad UUID - @test !JSONSchema.isvalid( - schema, FormatValidated( - "user@example.com", - "https://example.com", - "not-a-uuid", - "2023-01-01T12:00:00Z" - ) - ) - - # Invalid: bad date-time - @test !JSONSchema.isvalid( - schema, FormatValidated( - "user@example.com", - "https://example.com", - "550e8400-e29b-41d4-a716-446655440000", - "not-a-date" - ) - ) - end - - @testset "Validation - Verbose Mode" begin - @defaults struct VerboseTest - name::String = "" & (json = (minLength = 3,),) - age::Int = 0 & (json = (minimum = 0, maximum = 150),) - end - - schema = JSONSchema.schema(VerboseTest) - invalid = VerboseTest("ab", 200) - - # Test verbose=false (default) - @test !JSONSchema.isvalid(schema, invalid) - - # Test verbose=true (should print errors but we can't easily capture them) - # Just verify it still returns false - @test !JSONSchema.isvalid(schema, invalid, verbose = true) - end - - @testset "Validation - Complex Real-World Example" begin - @defaults struct ValidatedProduct - id::String = "" & (json = (format = "uuid",),) - name::String = "" & (json = (minLength = 1, maxLength = 200),) - price::Float64 = 0.0 & (json = (minimum = 0.0, exclusiveMinimum = true),) - tags::Vector{String} = String[] & (json = (uniqueItems = true, maxItems = 10),) - in_stock::Bool = true - quantity::Int = 0 & (json = (minimum = 0,),) - end - - schema = JSONSchema.schema(ValidatedProduct) - - # Valid product - valid_product = ValidatedProduct( - "550e8400-e29b-41d4-a716-446655440000", - "Test Product", - 19.99, - ["electronics", "sale"], - true, - 100 - ) - @test JSONSchema.isvalid(schema, valid_product) - - # Invalid: bad UUID - @test !JSONSchema.isvalid(schema, ValidatedProduct("not-uuid", "Test", 19.99, ["tag"], true, 100)) - - # Invalid: name too long - @test !JSONSchema.isvalid( - schema, ValidatedProduct( - "550e8400-e29b-41d4-a716-446655440000", - repeat("a", 201), - 19.99, - ["tag"], - true, - 100 - ) - ) - - # Invalid: price must be > 0 - @test !JSONSchema.isvalid( - schema, ValidatedProduct( - "550e8400-e29b-41d4-a716-446655440000", - "Test", - 0.0, - ["tag"], - true, - 100 - ) - ) - - # Invalid: duplicate tags - @test !JSONSchema.isvalid( - schema, ValidatedProduct( - "550e8400-e29b-41d4-a716-446655440000", - "Test", - 19.99, - ["tag", "tag"], - true, - 100 - ) - ) - - # Invalid: negative quantity - @test !JSONSchema.isvalid( - schema, ValidatedProduct( - "550e8400-e29b-41d4-a716-446655440000", - "Test", - 19.99, - ["tag"], - true, - -5 - ) - ) - end -end - -@testset "Composition - Union Types (oneOf)" begin - # Julia Union types automatically generate oneOf schemas - @defaults struct UnionType - value::Union{Int, String} = 0 - end - - schema = JSONSchema.schema(UnionType) - - # Check that oneOf was generated - @test haskey(schema["properties"]["value"], "oneOf") - @test length(schema["properties"]["value"]["oneOf"]) == 2 - - # Validate integer value - @test JSONSchema.isvalid(schema, UnionType(42)) - - # Validate string value - @test JSONSchema.isvalid(schema, UnionType("hello")) -end - -@testset "Composition - oneOf Manual" begin - # You can also manually specify oneOf with field tags - @defaults struct ManualOneOf - value::Int = 0 & ( - json = ( - oneOf = [ - Dict("type" => "integer", "minimum" => 0, "maximum" => 10), - Dict("type" => "integer", "minimum" => 100, "maximum" => 110), - ], - ), - ) - end - - schema = JSONSchema.schema(ManualOneOf) - - # Valid: matches first schema (0-10) - @test JSONSchema.isvalid(schema, ManualOneOf(5)) - - # Valid: matches second schema (100-110) - @test JSONSchema.isvalid(schema, ManualOneOf(105)) - - # Invalid: matches neither schema (in the gap) - @test !JSONSchema.isvalid(schema, ManualOneOf(50)) - - # Invalid: matches both schemas (if we had overlap, this would fail) - # The value must match EXACTLY one schema -end - -@testset "Composition - anyOf" begin - @defaults struct AnyOfExample - value::String = "" & ( - json = ( - anyOf = [ - Dict("minLength" => 5), # At least 5 chars - Dict("pattern" => "^[A-Z]"), # OR starts with uppercase - ], - ), - ) - end - - schema = JSONSchema.schema(AnyOfExample) - - # Valid: matches first constraint (>= 5 chars) - @test JSONSchema.isvalid(schema, AnyOfExample("hello")) - - # Valid: matches second constraint (starts with uppercase) - @test JSONSchema.isvalid(schema, AnyOfExample("Hi")) - - # Valid: matches both constraints - @test JSONSchema.isvalid(schema, AnyOfExample("Hello")) - - # Invalid: matches neither constraint - @test !JSONSchema.isvalid(schema, AnyOfExample("hi")) -end - -@testset "Composition - allOf" begin - @defaults struct AllOfExample - value::String = "" & ( - json = ( - allOf = [ - Dict("minLength" => 5), # At least 5 chars - Dict("pattern" => "^[A-Z]"), # AND starts with uppercase - ], - ), - ) - end - - schema = JSONSchema.schema(AllOfExample) - - # Valid: matches both constraints - @test JSONSchema.isvalid(schema, AllOfExample("Hello")) - @test JSONSchema.isvalid(schema, AllOfExample("WORLD")) - - # Invalid: doesn't match first constraint (too short) - @test !JSONSchema.isvalid(schema, AllOfExample("Hi")) - - # Invalid: doesn't match second constraint (lowercase start) - @test !JSONSchema.isvalid(schema, AllOfExample("hello")) -end - -@testset "Composition - Complex Union Types" begin - @defaults struct ComplexUnion3Types - # Union of three types - value::Union{Int, String, Bool} = 0 - end - - schema = JSONSchema.schema(ComplexUnion3Types) - - # Check oneOf was generated with 3 options - @test haskey(schema["properties"]["value"], "oneOf") - @test length(schema["properties"]["value"]["oneOf"]) == 3 - - # Validate each type - @test JSONSchema.isvalid(schema, ComplexUnion3Types(42)) - @test JSONSchema.isvalid(schema, ComplexUnion3Types("hello")) - @test JSONSchema.isvalid(schema, ComplexUnion3Types(true)) -end - -@testset "Composition - Nested Composition" begin - @defaults struct NestedComposition - value::Int = 0 & ( - json = ( - anyOf = [ - Dict( - "allOf" => [ - Dict("minimum" => 0), - Dict("maximum" => 10), - ] - ), - Dict( - "allOf" => [ - Dict("minimum" => 100), - Dict("maximum" => 110), - ] - ), - ], - ), - ) - end - - schema = JSONSchema.schema(NestedComposition) - - # Valid: in first range (0-10) - @test JSONSchema.isvalid(schema, NestedComposition(5)) - - # Valid: in second range (100-110) - @test JSONSchema.isvalid(schema, NestedComposition(105)) - - # Invalid: in neither range - @test !JSONSchema.isvalid(schema, NestedComposition(50)) -end - -@testset "Negation - not Combinator" begin - # Test 1: not with enum - @defaults struct ExcludedStatus - status::String = "" & ( - json = ( - not = Dict("enum" => ["deleted", "archived"]), - ), - ) - end - - schema = JSONSchema.schema(ExcludedStatus) - @test haskey(schema["properties"]["status"], "not") - - # Valid: status is not in the excluded list - @test JSONSchema.isvalid(schema, ExcludedStatus("active")) - @test JSONSchema.isvalid(schema, ExcludedStatus("pending")) - - # Invalid: status is in the excluded list - @test !JSONSchema.isvalid(schema, ExcludedStatus("deleted")) - @test !JSONSchema.isvalid(schema, ExcludedStatus("archived")) - - # Test 2: not with type constraint - @defaults struct NotStringValue - value::Union{Int, Bool, Nothing} = nothing & ( - json = ( - not = Dict("type" => "string"), - ), - ) - end - - schema2 = JSONSchema.schema(NotStringValue) - - # Valid: not a string - @test JSONSchema.isvalid(schema2, NotStringValue(42)) - @test JSONSchema.isvalid(schema2, NotStringValue(true)) - @test JSONSchema.isvalid(schema2, NotStringValue(nothing)) - - # Test 3: not with numeric constraint - @defaults struct ExcludedRange - value::Int = 0 & ( - json = ( - not = Dict("minimum" => 10, "maximum" => 20), - ), - ) - end - - schema3 = JSONSchema.schema(ExcludedRange) - - # Valid: outside the excluded range - @test JSONSchema.isvalid(schema3, ExcludedRange(5)) - @test JSONSchema.isvalid(schema3, ExcludedRange(25)) - - # Invalid: inside the excluded range - @test !JSONSchema.isvalid(schema3, ExcludedRange(10)) - @test !JSONSchema.isvalid(schema3, ExcludedRange(15)) - @test !JSONSchema.isvalid(schema3, ExcludedRange(20)) -end - -@testset "Array Contains" begin - # Test 1: contains with enum - must have at least one priority tag - @defaults struct TaskWithPriority - tags::Vector{String} = String[] & ( - json = ( - contains = Dict("enum" => ["urgent", "important", "critical"]), - ), - ) - end - - schema = JSONSchema.schema(TaskWithPriority) - @test haskey(schema["properties"]["tags"], "contains") - - # Valid: contains at least one priority tag - @test JSONSchema.isvalid(schema, TaskWithPriority(["urgent", "bug"])) - @test JSONSchema.isvalid(schema, TaskWithPriority(["feature", "important"])) - @test JSONSchema.isvalid(schema, TaskWithPriority(["critical"])) - @test JSONSchema.isvalid(schema, TaskWithPriority(["urgent", "important", "critical"])) - - # Invalid: no priority tags - @test !JSONSchema.isvalid(schema, TaskWithPriority(["bug", "feature"])) - @test !JSONSchema.isvalid(schema, TaskWithPriority(["normal"])) - @test !JSONSchema.isvalid(schema, TaskWithPriority(String[])) - - # Test 2: contains with pattern - @defaults struct EmailList - emails::Vector{String} = String[] & ( - json = ( - contains = Dict("pattern" => "^admin@"), - ), - ) - end - - schema2 = JSONSchema.schema(EmailList) - - # Valid: contains at least one admin email - @test JSONSchema.isvalid(schema2, EmailList(["admin@example.com", "user@example.com"])) - @test JSONSchema.isvalid(schema2, EmailList(["admin@test.com"])) - - # Invalid: no admin emails - @test !JSONSchema.isvalid(schema2, EmailList(["user@example.com"])) - - # Test 3: contains with numeric constraint - @defaults struct NumberList - numbers::Vector{Int} = Int[] & ( - json = ( - contains = Dict("minimum" => 100), - ), - ) - end - - schema3 = JSONSchema.schema(NumberList) - - # Valid: contains at least one number >= 100 - @test JSONSchema.isvalid(schema3, NumberList([50, 100, 150])) - @test JSONSchema.isvalid(schema3, NumberList([200])) - - # Invalid: all numbers < 100 - @test !JSONSchema.isvalid(schema3, NumberList([50, 75, 99])) -end - -@testset "Tuple Validation - Automatic" begin - # Test 1: Simple tuple - @defaults struct Point2D - coords::Tuple{Float64, Float64} = (0.0, 0.0) - end - - schema = JSONSchema.schema(Point2D) - @test haskey(schema["properties"]["coords"], "items") - @test schema["properties"]["coords"]["items"] isa Vector - @test length(schema["properties"]["coords"]["items"]) == 2 - - # Valid tuples - @test JSONSchema.isvalid(schema, Point2D((1.0, 2.0))) - @test JSONSchema.isvalid(schema, Point2D((0.0, 0.0))) - @test JSONSchema.isvalid(schema, Point2D((-5.5, 10.7))) - - # Test 2: Tuple with constraints via items tag - @defaults struct LatLon - location::Tuple{Float64, Float64} = (0.0, 0.0) & ( - json = ( - items = [ - Dict("type" => "number", "minimum" => -90, "maximum" => 90), # latitude - Dict("type" => "number", "minimum" => -180, "maximum" => 180), # longitude - ], - ), - ) - end - - schema2 = JSONSchema.schema(LatLon) - @test haskey(schema2["properties"]["location"], "items") - @test schema2["properties"]["location"]["items"] isa Vector - @test length(schema2["properties"]["location"]["items"]) == 2 - - # Valid: within lat/lon ranges - @test JSONSchema.isvalid(schema2, LatLon((45.0, -122.0))) - @test JSONSchema.isvalid(schema2, LatLon((0.0, 0.0))) - @test JSONSchema.isvalid(schema2, LatLon((90.0, 180.0))) - @test JSONSchema.isvalid(schema2, LatLon((-90.0, -180.0))) - - # Invalid: latitude out of range - @test !JSONSchema.isvalid(schema2, LatLon((95.0, 0.0))) - @test !JSONSchema.isvalid(schema2, LatLon((-95.0, 0.0))) - - # Invalid: longitude out of range - @test !JSONSchema.isvalid(schema2, LatLon((0.0, 190.0))) - @test !JSONSchema.isvalid(schema2, LatLon((0.0, -190.0))) - - # Test 3: Mixed type tuple - @defaults struct MixedTuple - data::Tuple{String, Int, Bool} = ("", 0, false) - end - - schema3 = JSONSchema.schema(MixedTuple) - @test haskey(schema3["properties"]["data"], "items") - @test schema3["properties"]["data"]["items"] isa Vector - @test length(schema3["properties"]["data"]["items"]) == 3 - @test schema3["properties"]["data"]["items"][1]["type"] == "string" - @test schema3["properties"]["data"]["items"][2]["type"] == "integer" - @test schema3["properties"]["data"]["items"][3]["type"] == "boolean" - - # Valid mixed tuple - @test JSONSchema.isvalid(schema3, MixedTuple(("hello", 42, true))) - - # Test 4: Tuple with specific constraints per position - @defaults struct RGB - color::Tuple{Int, Int, Int} = (0, 0, 0) & ( - json = ( - items = [ - Dict("minimum" => 0, "maximum" => 255), # R - Dict("minimum" => 0, "maximum" => 255), # G - Dict("minimum" => 0, "maximum" => 255), # B - ], - ), - ) - end - - schema4 = JSONSchema.schema(RGB) - - # Valid RGB values - @test JSONSchema.isvalid(schema4, RGB((255, 0, 0))) # Red - @test JSONSchema.isvalid(schema4, RGB((0, 255, 0))) # Green - @test JSONSchema.isvalid(schema4, RGB((0, 0, 255))) # Blue - @test JSONSchema.isvalid(schema4, RGB((128, 128, 128))) # Gray - - # Invalid: values out of range - @test !JSONSchema.isvalid(schema4, RGB((256, 0, 0))) - @test !JSONSchema.isvalid(schema4, RGB((0, -1, 0))) - @test !JSONSchema.isvalid(schema4, RGB((0, 0, 300))) -end - -@testset "Combined Advanced Features" begin - # Test combining not, contains, and tuple validation - @defaults struct AdvancedValidation - # Array that must contain a priority tag but not contain "spam" - tags::Vector{String} = String[] & ( - json = ( - contains = Dict("enum" => ["urgent", "important"]), - not = Dict("contains" => Dict("const" => "spam")), - ), - ) - - # Tuple with coordinate that must not be at origin - location::Tuple{Float64, Float64} = (0.0, 0.0) & ( - json = ( - items = [ - Dict("type" => "number"), - Dict("type" => "number"), - ], - not = Dict("enum" => [(0.0, 0.0)]), - ), - ) - end - - schema = JSONSchema.schema(AdvancedValidation) - - # Valid: has priority tag, no spam, not at origin - @test JSONSchema.isvalid(schema, AdvancedValidation(["urgent", "bug"], (1.0, 2.0))) - - # Invalid: no priority tag - @test !JSONSchema.isvalid(schema, AdvancedValidation(["bug"], (1.0, 2.0))) - - # Test 2: Nested not with composition - @defaults struct ComplexNot - value::Int = 0 & ( - json = ( - # Must be positive but not in the range 10-20 - minimum = 0, - not = Dict( - "allOf" => [ - Dict("minimum" => 10), - Dict("maximum" => 20), - ] - ), - ), - ) - end - - schema2 = JSONSchema.schema(ComplexNot) - - # Valid: positive and outside 10-20 range - @test JSONSchema.isvalid(schema2, ComplexNot(5)) - @test JSONSchema.isvalid(schema2, ComplexNot(25)) - @test JSONSchema.isvalid(schema2, ComplexNot(100)) - - # Invalid: in the excluded range - @test !JSONSchema.isvalid(schema2, ComplexNot(10)) - @test !JSONSchema.isvalid(schema2, ComplexNot(15)) - @test !JSONSchema.isvalid(schema2, ComplexNot(20)) - - # Invalid: negative (violates minimum) - @test !JSONSchema.isvalid(schema2, ComplexNot(-5)) -end - -@testset "Schema References (\$ref)" begin - @testset "Simple Refs - Basic Usage" begin - # Define nested types with unique names - @defaults struct RefAddress - street::String = "" - city::String = "" - zip::String = "" - end - - @defaults struct RefPerson - name::String = "" - address::RefAddress = RefAddress() - end - - # Test without refs (default behavior - inlined) - schema_inline = JSONSchema.schema(RefPerson) - @test !haskey(schema_inline.spec, "definitions") - @test !haskey(schema_inline.spec, "\$defs") - @test schema_inline.spec["properties"]["address"]["type"] == "object" - @test haskey(schema_inline.spec["properties"]["address"], "properties") - - # Test with refs=true (uses definitions) - schema_refs = JSONSchema.schema(RefPerson, refs = true) - @test haskey(schema_refs.spec, "definitions") - @test haskey(schema_refs.spec["definitions"], "RefAddress") - @test haskey(schema_refs.spec["definitions"], "RefPerson") - - # Verify RefPerson definition references RefAddress - person_def = schema_refs.spec["definitions"]["RefPerson"] - @test person_def["properties"]["address"]["\$ref"] == "#/definitions/RefAddress" - - # Verify RefAddress definition is complete - addr_def = schema_refs.spec["definitions"]["RefAddress"] - @test addr_def["type"] == "object" - @test haskey(addr_def, "properties") - @test haskey(addr_def["properties"], "street") - @test haskey(addr_def["properties"], "city") - @test haskey(addr_def["properties"], "zip") - - # Test with refs=:defs (Draft 2019+) - schema_defs = JSONSchema.schema(RefPerson, refs = :defs) - @test haskey(schema_defs.spec, "\$defs") - @test haskey(schema_defs.spec["\$defs"], "RefAddress") - @test haskey(schema_defs.spec["\$defs"], "RefPerson") - end - - @testset "Circular References" begin - # Define circular types: RefUser <-> RefComment - # Note: We use Int for author_id to avoid forward reference issues - @defaults struct RefComment - id::Int = 0 - text::String = "" - author_id::Int = 0 # Simplified to avoid circular definition issues - end - - @defaults struct RefUser - id::Int = 0 - name::String = "" - comments::Vector{RefComment} = RefComment[] - end - - # Without refs, this would inline and work - schema_inline = JSONSchema.schema(RefUser) - @test schema_inline.spec["properties"]["comments"]["items"]["type"] == "object" - - # With refs, types should be deduplicated - schema_refs = JSONSchema.schema(RefUser, refs = true) - - # Verify both types are in definitions - @test haskey(schema_refs.spec, "definitions") - @test haskey(schema_refs.spec["definitions"], "RefUser") - @test haskey(schema_refs.spec["definitions"], "RefComment") - - # Verify RefUser references RefComment - user_def = schema_refs.spec["definitions"]["RefUser"] - @test user_def["properties"]["comments"]["items"]["\$ref"] == "#/definitions/RefComment" - - # RefComment should have Int fields (no circular ref in this simplified version) - comment_def = schema_refs.spec["definitions"]["RefComment"] - @test comment_def["properties"]["id"]["type"] == "integer" - @test comment_def["properties"]["text"]["type"] == "string" - @test comment_def["properties"]["author_id"]["type"] == "integer" - end - - @testset "Type Deduplication" begin - @defaults struct RefTag - name::String = "" - end - - @defaults struct RefPost - title::String = "" - tags::Vector{RefTag} = RefTag[] - featured_tag::Union{Nothing, RefTag} = nothing - end - - schema = JSONSchema.schema(RefPost, refs = true) - - # Both RefTag and RefPost should be in definitions - @test haskey(schema.spec, "definitions") - @test haskey(schema.spec["definitions"], "RefTag") - @test haskey(schema.spec["definitions"], "RefPost") - - # Get the Post definition - post_def = schema.spec["definitions"]["RefPost"] - - # Both tags and featured_tag should reference the same RefTag definition - @test post_def["properties"]["tags"]["items"]["\$ref"] == "#/definitions/RefTag" - @test post_def["properties"]["featured_tag"]["oneOf"][1]["\$ref"] == "#/definitions/RefTag" - - # Verify RefTag appears only once (deduplication works) - @test length(keys(schema.spec["definitions"])) == 2 # RefPost and RefTag - end - - @testset "Validation with Refs" begin - @defaults struct RefContactInfo - email::String = "" & (json = (format = "email",),) - phone::String = "" & (json = (pattern = "^\\d{3}-\\d{3}-\\d{4}\$",),) - end - - @defaults struct RefCustomer - name::String = "" & (json = (minLength = 3,),) - contact::RefContactInfo = RefContactInfo() - end - - schema = JSONSchema.schema(RefCustomer, refs = true) - - # Valid customer - valid_customer = RefCustomer("Alice", RefContactInfo("alice@example.com", "555-123-4567")) - @test JSONSchema.isvalid(schema, valid_customer) - - # Invalid email in nested RefContactInfo - invalid_email = RefCustomer("Bob", RefContactInfo("not-an-email", "555-123-4567")) - @test !JSONSchema.isvalid(schema, invalid_email) - - # Invalid phone pattern in nested RefContactInfo - invalid_phone = RefCustomer("Carol", RefContactInfo("carol@example.com", "1234567890")) - @test !JSONSchema.isvalid(schema, invalid_phone) - - # Invalid name length in RefCustomer - invalid_name = RefCustomer("Al", RefContactInfo("al@example.com", "555-123-4567")) - @test !JSONSchema.isvalid(schema, invalid_name) - - # Multiple violations - invalid_multi = RefCustomer("X", RefContactInfo("bad-email", "bad-phone")) - @test !JSONSchema.isvalid(schema, invalid_multi) - end - - @testset "Shared Context Across Schemas" begin - @defaults struct RefDepartment - name::String = "" - end - - @defaults struct RefEmployee - name::String = "" - dept::RefDepartment = RefDepartment() - end - - @defaults struct RefProject - title::String = "" - lead_dept::RefDepartment = RefDepartment() - end - - # Create shared context - ctx = JSONSchema.SchemaContext() - - # Generate multiple schemas sharing the same context - employee_schema = JSONSchema.schema(RefEmployee, context = ctx) - project_schema = JSONSchema.schema(RefProject, context = ctx) - - # Both schemas should have definitions - @test haskey(employee_schema.spec, "definitions") - @test haskey(project_schema.spec, "definitions") - - # Both should reference RefDepartment - @test haskey(employee_schema.spec["definitions"], "RefDepartment") - @test haskey(project_schema.spec["definitions"], "RefDepartment") - - # RefDepartment definition should be the same in both - @test employee_schema.spec["definitions"]["RefDepartment"] == project_schema.spec["definitions"]["RefDepartment"] - - # Context should track all three types - @test haskey(ctx.type_names, RefEmployee) - @test haskey(ctx.type_names, RefDepartment) - @test haskey(ctx.type_names, RefProject) - end - - @testset "Primitives and Base Types Not Ref'd" begin - @defaults struct RefData - count::Int = 0 - values::Vector{Float64} = Float64[] - metadata::Dict{String, String} = Dict{String, String}() - end - - schema = JSONSchema.schema(RefData, refs = true) - - # Root type itself should be in definitions - @test haskey(schema.spec, "definitions") - @test haskey(schema.spec["definitions"], "RefData") - - # Check the definition's properties - primitives should not use refs - data_def = schema.spec["definitions"]["RefData"] - @test data_def["properties"]["count"]["type"] == "integer" - @test data_def["properties"]["values"]["type"] == "array" - @test data_def["properties"]["values"]["items"]["type"] == "number" - @test data_def["properties"]["metadata"]["type"] == "object" - - # Only RefData itself should be in definitions (no nested user types) - @test length(keys(schema.spec["definitions"])) == 1 - end - - @testset "Nested Refs - Three Levels Deep" begin - @defaults struct RefLevel3 - value::String = "" - end - - @defaults struct RefLevel2 - data::RefLevel3 = RefLevel3() - end - - @defaults struct RefLevel1 - nested::RefLevel2 = RefLevel2() - end - - schema = JSONSchema.schema(RefLevel1, refs = true) - - # All three levels should be in definitions - @test haskey(schema.spec["definitions"], "RefLevel1") - @test haskey(schema.spec["definitions"], "RefLevel2") - @test haskey(schema.spec["definitions"], "RefLevel3") - - # Verify reference chain - @test schema.spec["\$ref"] == "#/definitions/RefLevel1" - level1_def = schema.spec["definitions"]["RefLevel1"] - @test level1_def["properties"]["nested"]["\$ref"] == "#/definitions/RefLevel2" - level2_def = schema.spec["definitions"]["RefLevel2"] - @test level2_def["properties"]["data"]["\$ref"] == "#/definitions/RefLevel3" - end - - @testset "Complex Circular - BlogPost Example" begin - # RefBlogPost has author and comments - - @defaults struct RefBlogComment - id::Int = 0 - text::String = "" - author_id::Int = 0 - end - - @defaults struct RefBlogAuthor - id::Int = 0 - name::String = "" - posts::Vector{Int} = Int[] # Just IDs to avoid deeper circular - end - - @defaults struct RefBlogPost - title::String = "" - author::RefBlogAuthor = RefBlogAuthor() - comments::Vector{RefBlogComment} = RefBlogComment[] - end - - schema = JSONSchema.schema(RefBlogPost, refs = true) - - # All types should be defined - @test haskey(schema.spec["definitions"], "RefBlogPost") - @test haskey(schema.spec["definitions"], "RefBlogAuthor") - @test haskey(schema.spec["definitions"], "RefBlogComment") - - # Validate a complex instance - author = RefBlogAuthor(1, "Alice", [1, 2]) - comments = [RefBlogComment(1, "Great post!", 2), RefBlogComment(2, "Thanks!", 1)] - post = RefBlogPost("My Blog Post", author, comments) - - @test JSONSchema.isvalid(schema, post) - end - - @testset "Type Name Generation" begin - # Test module-qualified names - schema = JSONSchema.schema(JSON.Object{String, Any}, refs = true) - # Should handle parametric types - @test schema.spec["type"] == "object" - - # Test Main module types (no module prefix) - @defaults struct RefSimpleType - x::Int = 0 - end - - @defaults struct RefContainer - item::RefSimpleType = RefSimpleType() - end - - schema2 = JSONSchema.schema(RefContainer, refs = true) - # Should use simple name for Main module types - @test haskey(schema2.spec["definitions"], "RefSimpleType") - end -end - -@testset "Conditional Schemas (if/then/else)" begin - @testset "Basic if/then" begin - # Create a manual schema with if/then - schema_obj = JSON.Object{String, Any}( - "type" => "object", - "properties" => JSON.Object{String, Any}( - "country" => JSON.Object{String, Any}("type" => "string"), - "postal_code" => JSON.Object{String, Any}("type" => "string") - ), - "if" => JSON.Object{String, Any}( - "properties" => JSON.Object{String, Any}( - "country" => JSON.Object{String, Any}("const" => "US") - ) - ), - "then" => JSON.Object{String, Any}( - "properties" => JSON.Object{String, Any}( - "postal_code" => JSON.Object{String, Any}("pattern" => "^[0-9]{5}\$") - ) - ) - ) - - schema = JSONSchema.Schema{Any}(Any, schema_obj, nothing) - - # Test with US country - postal_code must match US format - us_address = Dict("country" => "US", "postal_code" => "12345") - @test JSONSchema.isvalid(schema, us_address) - - us_address_invalid = Dict("country" => "US", "postal_code" => "ABC") - @test !JSONSchema.isvalid(schema, us_address_invalid) - - # Test with non-US country - postal_code not restricted - uk_address = Dict("country" => "UK", "postal_code" => "ABC 123") - @test JSONSchema.isvalid(schema, uk_address) - end - - @testset "if/then/else" begin - schema_obj = JSON.Object{String, Any}( - "type" => "object", - "properties" => JSON.Object{String, Any}( - "type" => JSON.Object{String, Any}("type" => "string"), - "value" => JSON.Object{String, Any}() - ), - "if" => JSON.Object{String, Any}( - "properties" => JSON.Object{String, Any}( - "type" => JSON.Object{String, Any}("const" => "number") - ) - ), - "then" => JSON.Object{String, Any}( - "properties" => JSON.Object{String, Any}( - "value" => JSON.Object{String, Any}("type" => "number") - ) - ), - "else" => JSON.Object{String, Any}( - "properties" => JSON.Object{String, Any}( - "value" => JSON.Object{String, Any}("type" => "string") - ) - ) - ) - - schema = JSONSchema.Schema{Any}(Any, schema_obj, nothing) - - # If type is "number", value must be a number - @test JSONSchema.isvalid(schema, Dict("type" => "number", "value" => 42)) - @test !JSONSchema.isvalid(schema, Dict("type" => "number", "value" => "hello")) - - # If type is not "number", value must be a string - @test JSONSchema.isvalid(schema, Dict("type" => "text", "value" => "hello")) - @test !JSONSchema.isvalid(schema, Dict("type" => "text", "value" => 42)) - end -end - -@testset "Advanced Object Validation" begin - @testset "propertyNames - struct" begin - # Create a struct and validate property names - @defaults struct PropNamesTest - valid_name::String = "" - another_valid::Int = 0 - end - - schema_obj = JSON.Object{String, Any}( - "type" => "object", - "properties" => JSON.Object{String, Any}( - "valid_name" => JSON.Object{String, Any}("type" => "string"), - "another_valid" => JSON.Object{String, Any}("type" => "integer") - ), - "propertyNames" => JSON.Object{String, Any}( - "pattern" => "^[a-z_]+\$" - ) - ) - - schema = JSONSchema.Schema{PropNamesTest}(PropNamesTest, schema_obj, nothing) - - # Valid: all property names match pattern - valid_instance = PropNamesTest("test", 42) - @test JSONSchema.isvalid(schema, valid_instance) - end - - @testset "propertyNames - Dict" begin - schema_obj = JSON.Object{String, Any}( - "type" => "object", - "propertyNames" => JSON.Object{String, Any}( - "pattern" => "^[A-Z]+\$" - ) - ) - - schema = JSONSchema.Schema{Any}(Any, schema_obj, nothing) - - # Valid: all keys are uppercase - @test JSONSchema.isvalid(schema, Dict("FOO" => 1, "BAR" => 2)) - - # Invalid: some keys have lowercase - @test !JSONSchema.isvalid(schema, Dict("FOO" => 1, "bar" => 2)) - end - - @testset "patternProperties - Dict" begin - schema_obj = JSON.Object{String, Any}( - "type" => "object", - "patternProperties" => JSON.Object{String, Any}( - "^str_" => JSON.Object{String, Any}("type" => "string"), - "^num_" => JSON.Object{String, Any}("type" => "number") - ) - ) - - schema = JSONSchema.Schema{Any}(Any, schema_obj, nothing) - - # Valid: keys match patterns with correct value types - @test JSONSchema.isvalid(schema, Dict("str_name" => "hello", "num_count" => 42)) - - # Invalid: str_ key with number value - @test !JSONSchema.isvalid(schema, Dict("str_name" => 123)) - - # Invalid: num_ key with string value - @test !JSONSchema.isvalid(schema, Dict("num_count" => "hello")) - - # Valid: non-matching keys are not validated - @test JSONSchema.isvalid(schema, Dict("other" => [1, 2, 3])) - end - - @testset "dependencies - array form (struct)" begin - @defaults struct DepsTest - credit_card::Union{Nothing, String} = nothing - billing_address::Union{Nothing, String} = nothing - security_code::Union{Nothing, String} = nothing - end - - schema_obj = JSON.Object{String, Any}( - "type" => "object", - "properties" => JSON.Object{String, Any}( - "credit_card" => JSON.Object{String, Any}("type" => ["string", "null"]), - "billing_address" => JSON.Object{String, Any}("type" => ["string", "null"]), - "security_code" => JSON.Object{String, Any}("type" => ["string", "null"]) - ), - "dependencies" => JSON.Object{String, Any}( - "credit_card" => ["billing_address", "security_code"] - ) - ) - - schema = JSONSchema.Schema{DepsTest}(DepsTest, schema_obj, nothing) - - # Valid: credit_card present with required dependencies - @test JSONSchema.isvalid(schema, DepsTest("1234", "123 Main St", "999")) - - # Valid: credit_card absent - @test JSONSchema.isvalid(schema, DepsTest(nothing, nothing, nothing)) - - # Invalid: credit_card present but missing billing_address - @test !JSONSchema.isvalid(schema, DepsTest("1234", nothing, "999")) - end - - @testset "dependencies - array form (Dict)" begin - schema_obj = JSON.Object{String, Any}( - "type" => "object", - "dependencies" => JSON.Object{String, Any}( - "credit_card" => ["billing_address"] - ) - ) - - schema = JSONSchema.Schema{Any}(Any, schema_obj, nothing) - - # Valid: credit_card with billing_address - @test JSONSchema.isvalid(schema, Dict("credit_card" => "1234", "billing_address" => "123 Main")) - - # Valid: no credit_card - @test JSONSchema.isvalid(schema, Dict("name" => "Alice")) - - # Invalid: credit_card without billing_address - @test !JSONSchema.isvalid(schema, Dict("credit_card" => "1234")) - end - - @testset "dependencies - schema form" begin - schema_obj = JSON.Object{String, Any}( - "type" => "object", - "properties" => JSON.Object{String, Any}( - "name" => JSON.Object{String, Any}("type" => "string"), - "age" => JSON.Object{String, Any}("type" => "integer") - ), - "dependencies" => JSON.Object{String, Any}( - "age" => JSON.Object{String, Any}( - "properties" => JSON.Object{String, Any}( - "birth_year" => JSON.Object{String, Any}("type" => "integer") - ), - "required" => ["birth_year"] - ) - ) - ) - - schema = JSONSchema.Schema{Any}(Any, schema_obj, nothing) - - # Valid: age present with birth_year - @test JSONSchema.isvalid(schema, Dict("name" => "Alice", "age" => 30, "birth_year" => 1994)) - - # Valid: no age - @test JSONSchema.isvalid(schema, Dict("name" => "Bob")) - - # Invalid: age present without birth_year - @test !JSONSchema.isvalid(schema, Dict("name" => "Carol", "age" => 25)) - end - - @testset "additionalProperties - struct (false)" begin - @defaults struct StrictStruct - name::String = "" - age::Int = 0 - end - - schema_obj = JSON.Object{String, Any}( - "type" => "object", - "properties" => JSON.Object{String, Any}( - "name" => JSON.Object{String, Any}("type" => "string") - ), - "additionalProperties" => false - ) - - schema = JSONSchema.Schema{StrictStruct}(StrictStruct, schema_obj, nothing) - - # This would fail because 'age' is not in the schema - # Note: For structs, all fields are present, so we can't really test this - # in the same way as Dict. The validation checks if struct fields - # are not in the schema's properties. - @test !JSONSchema.isvalid(schema, StrictStruct("Alice", 30)) - end - - @testset "additionalProperties - struct (schema)" begin - @defaults struct FlexStruct - name::String = "" - extra1::Int = 0 - end - - schema_obj = JSON.Object{String, Any}( - "type" => "object", - "properties" => JSON.Object{String, Any}( - "name" => JSON.Object{String, Any}("type" => "string") - ), - "additionalProperties" => JSON.Object{String, Any}("type" => "integer") - ) - - schema = JSONSchema.Schema{FlexStruct}(FlexStruct, schema_obj, nothing) - - # Valid: extra1 is integer (matches additionalProperties) - @test JSONSchema.isvalid(schema, FlexStruct("Alice", 42)) - end -end - -@testset "Advanced Array Validation (additionalItems)" begin - @testset "additionalItems - false" begin - schema_obj = JSON.Object{String, Any}( - "type" => "array", - "items" => [ - JSON.Object{String, Any}("type" => "string"), - JSON.Object{String, Any}("type" => "number"), - ], - "additionalItems" => false - ) - - schema = JSONSchema.Schema{Any}(Any, schema_obj, nothing) - - # Valid: exactly 2 items matching the tuple schema - @test JSONSchema.isvalid(schema, ["hello", 42]) - - # Invalid: more than 2 items - @test !JSONSchema.isvalid(schema, ["hello", 42, "extra"]) - end - - @testset "additionalItems - schema" begin - schema_obj = JSON.Object{String, Any}( - "type" => "array", - "items" => [ - JSON.Object{String, Any}("type" => "string"), - JSON.Object{String, Any}("type" => "number"), - ], - "additionalItems" => JSON.Object{String, Any}("type" => "boolean") - ) - - schema = JSONSchema.Schema{Any}(Any, schema_obj, nothing) - - # Valid: first two items match tuple, rest are booleans - @test JSONSchema.isvalid(schema, ["hello", 42, true, false]) - - # Invalid: additional item is not boolean - @test !JSONSchema.isvalid(schema, ["hello", 42, "not a boolean"]) - - # Valid: exactly 2 items (no additional items) - @test JSONSchema.isvalid(schema, ["hello", 42]) - end - - @testset "additionalItems with no items constraint" begin - # When items is not an array, additionalItems has no effect - schema_obj = JSON.Object{String, Any}( - "type" => "array", - "items" => JSON.Object{String, Any}("type" => "string"), - "additionalItems" => false - ) - - schema = JSONSchema.Schema{Any}(Any, schema_obj, nothing) - - # Valid: all items are strings (additionalItems doesn't apply) - @test JSONSchema.isvalid(schema, ["hello", "world", "foo"]) - end -end - -@testset "Validation API and Formats" begin - @testset "validate vs isvalid" begin - @defaults struct ValidateTest - val::Int = 0 & (json = (minimum = 10,),) - end - schema = JSONSchema.schema(ValidateTest) - - # Valid - validate returns nothing on success - instance = ValidateTest(15) - res = JSONSchema.validate(schema, instance) - @test res === nothing - @test isvalid(schema, instance) == true - - # Invalid - validate returns ValidationResult on failure - instance_invalid = ValidateTest(5) - res_invalid = JSONSchema.validate(schema, instance_invalid) - @test res_invalid isa JSONSchema.ValidationResult - @test res_invalid.is_valid == false - @test !isempty(res_invalid.errors) - @test length(res_invalid.errors) == 1 - @test occursin("less than minimum", res_invalid.errors[1]) - @test isvalid(schema, instance_invalid) == false - end - - @testset "Improved Format Validation" begin - @defaults struct FormatTestV2 - email::String = "" & (json = (format = "email",),) - uri::String = "" & (json = (format = "uri",),) - dt::String = "" & (json = (format = "date-time",),) - end - schema = JSONSchema.schema(FormatTestV2) - - # Email - @test JSONSchema.isvalid(schema, FormatTestV2("test@example.com", "http://a.com", "2023-01-01T12:00:00Z")) - @test !JSONSchema.isvalid(schema, FormatTestV2("test @example.com", "http://a.com", "2023-01-01T12:00:00Z")) - @test !JSONSchema.isvalid(schema, FormatTestV2("test", "http://a.com", "2023-01-01T12:00:00Z")) - - # URI - @test JSONSchema.isvalid(schema, FormatTestV2("a@b.c", "ftp://example.com", "2023-01-01T12:00:00Z")) - @test JSONSchema.isvalid(schema, FormatTestV2("a@b.c", "mailto:user@host", "2023-01-01T12:00:00Z")) - @test !JSONSchema.isvalid(schema, FormatTestV2("a@b.c", "example.com", "2023-01-01T12:00:00Z")) - @test !JSONSchema.isvalid(schema, FormatTestV2("a@b.c", "http://exa mple.com", "2023-01-01T12:00:00Z")) - - # Date-time - @test JSONSchema.isvalid(schema, FormatTestV2("a@b.c", "http://a.com", "2023-01-01T12:00:00Z")) - @test JSONSchema.isvalid(schema, FormatTestV2("a@b.c", "http://a.com", "2023-01-01T12:00:00+00:00")) - @test JSONSchema.isvalid(schema, FormatTestV2("a@b.c", "http://a.com", "2023-01-01T12:00:00.123Z")) - @test !JSONSchema.isvalid(schema, FormatTestV2("a@b.c", "http://a.com", "2023-01-01T12:00:00")) # No timezone - @test !JSONSchema.isvalid(schema, FormatTestV2("a@b.c", "http://a.com", "2023/01/01")) - end -end diff --git a/test/runtests.jl b/test/runtests.jl index e4c6217..68f2e62 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,9 +1,343 @@ -# Copyright (c) 2018-2026: fredo-dedup, quinnj, and contributors +# Copyright (c) 2018: fredo-dedup and contributors # # Use of this source code is governed by an MIT-style license that can be found # in the LICENSE.md file or at https://opensource.org/licenses/MIT. +using JSONSchema using Test +import Downloads +import HTTP +import JSON +import JSON3 +import OrderedCollections +import ZipFile -include("generation.jl") -include("validation.jl") +const TEST_SUITE_URL = "https://github.com/json-schema-org/JSON-Schema-Test-Suite/archive/23.1.0.zip" + +const SCHEMA_TEST_DIR = let + dest_dir = mktempdir() + dest_file = joinpath(dest_dir, "test-suite.zip") + Downloads.download(TEST_SUITE_URL, dest_file) + for f in ZipFile.Reader(dest_file).files + filename = joinpath(dest_dir, "test-suite", f.name) + if endswith(filename, "/") + mkpath(filename) + else + write(filename, read(f, String)) + end + end + joinpath(dest_dir, "test-suite", "JSON-Schema-Test-Suite-23.1.0", "tests") +end + +const LOCAL_TEST_DIR = mktempdir(SCHEMA_TEST_DIR) + +# Write test files for locally referenced schema files. +# +# These files have the same format as JSON Schema org test files. They are written +# to a sibling directory to JSON-Schema-Test-Suite-master/tests/draft* directories +# so they can be consumed the same way as the draft*/*.json test files. +# sibling directory for testing a relative path containing "../" +const REF_LOCAL_TEST_DIR = mktempdir(SCHEMA_TEST_DIR) + +write( + joinpath(REF_LOCAL_TEST_DIR, "localReferenceSchemaOne.json"), + """{ + "type": "object", + "properties": {"localRefOneResult": {"type": "string"}} +}""", +) + +write( + joinpath(REF_LOCAL_TEST_DIR, "localReferenceSchemaTwo.json"), + """{ + "type": "object", + "properties": {"localRefTwoResult": {"type": "number"}} +}""", +) + +write( + joinpath(REF_LOCAL_TEST_DIR, "nestedLocalReference.json"), + """{ + "type": "object", + "properties": { + "result": { + "\$ref": "file:localReferenceSchemaOne.json#/properties/localRefOneResult" + } + } +}""", +) + +write( + joinpath(LOCAL_TEST_DIR, "localReferenceTest.json"), + """[{ + "description": "test locally referenced schemas", + "schema": { + "type": "object", + "properties": { + "result1": { "\$ref": "file:../$(basename(abspath(REF_LOCAL_TEST_DIR)))/localReferenceSchemaOne.json#/properties/localRefOneResult" }, + "result2": { "\$ref": "../$(basename(abspath(REF_LOCAL_TEST_DIR)))/localReferenceSchemaTwo.json#/properties/localRefTwoResult" } + }, + "oneOf": [{ + "required": ["result1"] + }, { + "required": ["result2"] + }] + }, + "tests": [{ + "description": "reference only local schema 1", + "data": {"result1": "some text"}, + "valid": true + }, { + "description": "reference only local schema 2", + "data": {"result2": 1234}, + "valid": true + }, { + "description": "incorrect reference to local schema 1", + "data": {"result1": true}, + "valid": false + }, { + "description": "reference neither local schemas", + "data": {"result": true}, + "valid": false + }, { + "description": "reference both local schemas", + "data": {"result1": "some text", "result2": 500}, + "valid": false + }] +}]""", +) + +write( + joinpath(LOCAL_TEST_DIR, "nestedLocalReferenceTest.json"), + """[{ + "description": "test locally referenced schemas", + "schema": { + "type": "object", + "properties": { + "result": { + "\$ref": "file:../$(basename(abspath(REF_LOCAL_TEST_DIR)))/nestedLocalReference.json#/properties/result" + } + } + }, + "tests": [{ + "description": "nested reference, correct type", + "data": {"result": "some text"}, + "valid": true + }, { + "description": "nested reference, incorrect type", + "data": {"result": 1234}, + "valid": false + }] +}]""", +) + +is_json(n) = endswith(n, ".json") + +function test_draft_directory(server, dir, json_parse_fn::Function) + @testset "$(file)" for file in filter(is_json, readdir(dir)) + if file == "unknownKeyword.json" + # This is an optional test, and to be honest, it is pretty minor. It + # relates to how we handle $id if the user includes part of a schema + # that we don't know how to parse. As a low priority action item, we + # could come back to this. + continue + end + file_path = joinpath(dir, file) + @testset "$(tests["description"])" for tests in json_parse_fn(file_path) + # TODO(odow): fix this failing test + fails = + ["retrieved nested refs resolve relative to their URI not \$id"] + if file == "refRemote.json" && tests["description"] in fails + continue + end + is_bool = tests["schema"] isa Bool + parent_dir = ifelse(is_bool, abspath("."), dirname(file_path)) + schema = JSONSchema.Schema(tests["schema"]; parent_dir) + @testset "$(test["description"])" for test in tests["tests"] + @test isvalid(schema, test["data"]) == test["valid"] + end + end + end + return +end + +@testset "JSON-Schema-Test-Suite" begin + GLOBAL_TEST_DIR = Ref{String}("") + server = HTTP.Sockets.listen(HTTP.ip"127.0.0.1", 1234) + HTTP.serve!("127.0.0.1", 1234; server = server) do req + # Make sure to strip first character (`/`) from the target, otherwise it + # will infer as a file in the root directory. + file = joinpath(GLOBAL_TEST_DIR[], "../../remotes", req.target[2:end]) + return HTTP.Response(200, read(file, String)) + end + @testset "$dir" for dir in [ + "draft4", + "draft6", + "draft7", + basename(abspath(LOCAL_TEST_DIR)), + ] + GLOBAL_TEST_DIR[] = joinpath(SCHEMA_TEST_DIR, dir) + @testset "JSON" begin + test_draft_directory(server, GLOBAL_TEST_DIR[], JSON.parsefile) + end + @testset "JSON3" begin + test_draft_directory(server, GLOBAL_TEST_DIR[], JSON3.read) + end + end + close(server) +end + +@testset "Validate and diagnose" begin + schema = JSONSchema.Schema( + Dict( + "properties" => Dict("foo" => Dict(), "bar" => Dict()), + "required" => ["foo"], + ), + ) + data_pass = Dict("foo" => true) + data_fail = Dict("bar" => 12.5) + @test JSONSchema.validate(schema, data_pass) === nothing + ret = JSONSchema.validate(schema, data_fail) + fail_msg = """Validation failed: + path: top-level + instance: $(data_fail) + schema key: required + schema value: ["foo"] + """ + @test ret !== nothing + @test sprint(show, ret) == fail_msg + @test JSONSchema.diagnose(data_pass, schema) === nothing + @test JSONSchema.diagnose(data_fail, schema) == fail_msg +end + +@testset "parentFileDirectory deprecation" begin + schema = JSONSchema.Schema("{}"; parentFileDirectory = ".") + @test typeof(schema) == Schema +end + +@testset "Schemas" begin + schema = JSONSchema.Schema("""{ + \"properties\": { + \"foo\": {}, + \"bar\": {} + }, + \"required\": [\"foo\"] + }""") + @test typeof(schema) == Schema + @test typeof(schema.data) <: AbstractDict{String,Any} + schema_2 = JSONSchema.Schema(false) + @test typeof(schema_2) == Schema + @test typeof(schema_2.data) == Bool +end + +@testset "Base.show" begin + schema = JSONSchema.Schema("{}") + @test sprint(show, schema) == "A JSONSchema" +end + +@testset "errors" begin + @test_throws( + ErrorException("missing property 'Foo' in $(JSON.parse("{}"))."), + JSONSchema.Schema("""{ + "type": "object", + "properties": {"version": {"\$ref": "#/definitions/Foo"}}, + "definitions": {} + }"""), + ) + @test_throws( + ErrorException("unmanaged type in ref resolution $(Int64): 1."), + JSONSchema.Schema("""{ + "type": "object", + "properties": {"version": {"\$ref": "#/definitions/Foo"}}, + "definitions": 1 + }""") + ) + @test_throws( + ErrorException("expected integer array index instead of 'Foo'."), + JSONSchema.Schema("""{ + "type": "object", + "properties": {"version": {"\$ref": "#/definitions/Foo"}}, + "definitions": [1, 2] + }""") + ) + @test_throws( + ErrorException("item index 3 is larger than array $(Any[1, 2])."), + JSONSchema.Schema("""{ + "type": "object", + "properties": {"version": {"\$ref": "#/definitions/3"}}, + "definitions": [1, 2] + }""") + ) + @test_throws( + ErrorException("cannot support circular references in schema."), + JSONSchema.validate( + JSONSchema.Schema("""{ + "type": "object", + "properties": { + "version": { + "\$ref": "#/definitions/Foo" + } + }, + "definitions": { + "Foo": { + "\$ref": "#/definitions/Foo" + } + } + }"""), + Dict("version" => 1), + ) + ) +end + +@testset "_is_type" begin + for (key, val) in Dict( + :array => [1, 2], + :boolean => true, + :integer => 1, + :number => 1.0, + :null => nothing, + :object => Dict(), + :string => "string", + ) + @test JSONSchema._is_type(val, Val(Symbol(key))) + @test !JSONSchema._is_type(:not_a_json_type, Val(Symbol(key))) + end + @test JSONSchema._is_type(missing, Val(:null)) + + @test !JSONSchema._is_type(true, Val(:number)) + @test !JSONSchema._is_type(true, Val(:integer)) +end + +@testset "OrderedDict" begin + schema = JSONSchema.Schema( + Dict( + "properties" => Dict("foo" => Dict(), "bar" => Dict()), + "required" => ["foo"], + ), + ) + data_pass = OrderedCollections.OrderedDict("foo" => true) + data_fail = OrderedCollections.OrderedDict("bar" => 12.5) + @test JSONSchema.validate(schema, data_pass) === nothing + @test JSONSchema.validate(schema, data_fail) != nothing +end + +@testset "Inverse argument order" begin + schema = JSONSchema.Schema( + Dict( + "properties" => Dict("foo" => Dict(), "bar" => Dict()), + "required" => ["foo"], + ), + ) + data_pass = Dict("foo" => true) + data_fail = Dict("bar" => 12.5) + @test JSONSchema.validate(data_pass, schema) === nothing + @test JSONSchema.validate(data_fail, schema) != nothing + @test isvalid(data_pass, schema) + @test !isvalid(data_fail, schema) +end + +@testset "exports" begin + @test Schema === JSONSchema.Schema + @test validate === JSONSchema.validate + @test diagnose === JSONSchema.diagnose +end diff --git a/test/validation.jl b/test/validation.jl deleted file mode 100644 index e0a8e4e..0000000 --- a/test/validation.jl +++ /dev/null @@ -1,122 +0,0 @@ -# Copyright (c) 2018-2026: fredo-dedup, quinnj, and contributors -# -# Use of this source code is governed by an MIT-style license that can be found -# in the LICENSE.md file or at https://opensource.org/licenses/MIT. - -using JSON -using JSONSchema -using Tar -using Test - -function tar_files(tarball::String) - data = Dict{String, Vector{UInt8}}() - buf = Vector{UInt8}(undef, Tar.DEFAULT_BUFFER_SIZE) - io = IOBuffer() - open(tarball) do tio - Tar.read_tarball(_ -> true, tio; buf = buf) do header, _ - if header.type == :file - take!(io) # In case there are multiple entries for the file - Tar.read_data(tio, io; size = header.size, buf) - data[header.path] = take!(io) - end - end - end - return data -end - -function make_remote_loader(files::Dict{String, Vector{UInt8}}, draft::String) - cache = Dict{String, Vector{UInt8}}() - draft_prefix = "remotes/$(draft)/" - return function (uri::String) - if haskey(cache, uri) - return cache[uri] - end - - m = match(r"^https?://localhost:1234/(.*)$", uri) - if m !== nothing - rel_path = m.captures[1] - data = get(files, "remotes/" * rel_path, nothing) - if data === nothing - data = get(files, draft_prefix * rel_path, nothing) - end - if data !== nothing - cache[uri] = data - return data - end - end - - return nothing - end -end - -function draft_entries(files::Dict{String, Vector{UInt8}}, draft::String) - prefix = "tests/$(draft)/" - paths = sort( - [ - path for path in keys(files) - if startswith(path, prefix) && - endswith(path, ".json") && - !occursin("/__MACOSX/", path) && - !startswith(basename(path), "._") - ] - ) - return [(path, files[path]) for path in paths] -end - -function run_test_file(draft::String, path::String, data::Vector{UInt8}, failures, remote_loader) - groups = JSON.parse(data) - return @testset "$(basename(path))" begin - for group in groups - group_desc = string(get(group, "description", "unknown")) - schema = JSONSchema.Schema(group["schema"]) - resolver = JSONSchema.RefResolver(schema.spec; remote_loader = remote_loader) - @testset "$group_desc" begin - for case in group["tests"] - case_desc = string(get(case, "description", "case")) - expected = case["valid"] - value = case["data"] - @testset "$case_desc" begin - result = try - # validate returns nothing on success, ValidationResult on failure - JSONSchema.validate(schema, value; resolver = resolver) === nothing - catch - :error - end - if result == expected - @test result == expected - else - if failures !== nothing - push!(failures, (draft = draft, file = basename(path), group = group_desc, case = case_desc, expected = expected, result = result)) - end - @test_broken result == expected - end - end - end - end - end - end -end - -const schema_test_suite = tar_files(joinpath(@__DIR__, "JSONSchemaTestSuite.tar")) -const drafts = ["draft4", "draft6", "draft7"] -const report_path = get(ENV, "JSONSCHEMA_TESTSUITE_REPORT", nothing) -const suite_failures = report_path === nothing ? nothing : [] - -@testset "JSON-Schema-Test-Suite" begin - for draft in drafts - @testset "$draft" begin - remote_loader = make_remote_loader(schema_test_suite, draft) - for (path, data) in draft_entries(schema_test_suite, draft) - run_test_file(draft, path, data, suite_failures, remote_loader) - end - end - end -end - -if suite_failures !== nothing && !isempty(suite_failures) - open(report_path, "w") do io - for item in suite_failures - println(io, "$(item.draft)\t$(item.file)\t$(item.group)\t$(item.case)\texpected=$(item.expected)\tresult=$(item.result)") - end - end -end