ata-validator-crystal

Version, currently 0.2.03 versions

github.com/GroophyLifefor/ata-validator-crystal

Crystal FFI bindings for ata-validator, the C++20 JSON Schema validator

2 stars
0 dependents
License: MIT

Installation

# Add this to your shard.yml
dependencies:
  ata-validator-crystal:
    github: GroophyLifefor/ata-validator-crystal
    version: ~> 0.2.0

Then run:

shards install

shard.yml

Crystal
>= 1.15.0
License
MIT
Author
Murat Kirazkaya

Dependencies

Development Dependencies

README

ata-validator-crystal

Crystal bindings for ata-validator — a high-performance C++20 JSON Schema validator.

The C++ core uses simdjson and the RE2 regex engine; this shard only wraps its pure C API (ata_c.h). Struct layouts and function signatures match ata_c.h exactly.

Install

Add to shard.yml:

dependencies:
  ata-validator-crystal:
    github: groophylifefor/ata-validator-crystal
    version: ~> 0.1.0
shards install

Native library

Two parts are required:

  • ata.lib (or libata.a) — at link time, passed to crystal build via /LIBPATH (Windows/MSVC) or -L (Unix)
  • ata.dll (or libata.so) — at runtime, next to the executable, on PATH, or pointed to by ATA_VALIDATOR_LIB

To build the library into libata/ (the ata-validator source must be a sibling directory):

crystal run scripts/build_native.cr

This script compiles the shared library in ata-validator with CMake and copies libata/ata.dll + libata/ata.lib into the package root. (Default source path is ../../ata-validator; override with the ATA_VALIDATOR_SRC env var.)

Build

Windows (MSVC linker):

crystal build src/main.cr --link-flags "/LIBPATH:ata-validator-crystal/libata"

Linux/macOS:

crystal build src/main.cr --link-flags "-Lata-validator-crystal/libata -lata"

Usage

require "ata-validator-crystal"

schema = <<-JSON
{
  "type": "object",
  "properties": {
    "name": {"type": "string", "minLength": 1},
    "age":  {"type": "integer", "minimum": 0}
  },
  "required": ["name"]
}
JSON

puts "ata v#{AtaValidator.version}"

# Reuse a compiled schema
validator = AtaValidator::Validator.new(schema)

result = validator.validate(%({"name": "Mert", "age": 28}))
puts result.valid                          # => true

result = validator.validate(%({"age": -1}))
puts result.valid                          # => false
result.errors.each do |e|
  puts "#{e.path}: #{e.message}"           # => /age: value -1.000000 < minimum 0.000000
end

validator.valid?(%({"name": "Mert"}))      # => true (quick boolean check)
validator.close

# One-shot (compiles the schema on every call)
AtaValidator.validate(schema, %({"name": "Mert"})).valid

Schema DSL

Ata.object is a compile-time macro: it reads the block's AST and generates a real struct with class methods schema_json, validate, valid? and from_json, plus a typed getter for every field. Every field is required unless optional: true is passed.

Ata.object User do
  string :name, min: 3, max: 10
  int :age, gt: 0, lte: 120
  string :email, format: "email", optional: true
  bool :active
end

User.valid?(%({"name": "Mert", "age": 28, "active": true}))   # => true
User.valid?(%({"name": "Me", "age": 28, "active": true}))     # => false (minLength)
User.valid?(%({"name": "Mert", "age": 0, "active": true}))    # => false (exclusiveMinimum)

u = User.from_json(%({"name": "Mert", "age": 28, "active": true}))
u.name                # => "Mert"  (typed getter)
u.email               # => nil     (optional field)

puts User.schema_json
# {"type":"object","properties":{"name":{"type":"string","minLength":3,"maxLength":10},
#  "age":{"type":"integer","exclusiveMinimum":0,"maximum":120},...},"required":["name","age","active"]}
MethodArgumentsJSON Schema
stringmin / maxminLength / maxLength, pattern, format, values:enum{"type": "string", ...}
intgt / ltexclusiveMinimum / exclusiveMaximum, gte / lteminimum / maximum{"type": "integer", ...}
floatsame as int{"type": "number", ...}
bool{"type": "boolean"}
any{} (any value accepted)
arrayof: :string/:int/:float/:bool/:any or a nested Ata.object schema; min_items / max_items{"type": "array", "items": {...}}
objectof: a nested Ata.object schemaembeds the schema as-is

Nested schemas compose:

Ata.object Address do
  string :city, min: 1
  int :zip, gte: 0
end

Ata.object Person do
  string :name, min: 3
  object :address, of: Address
  array :tags, of: :string, min_items: 1
end

gt: / lt: emit the draft-06/07 boolean-independent form ("exclusiveMinimum": 0), which is what the native validator implements.

API

  • AtaValidator.version : String
  • AtaValidator::Validator.new(schema_json) — compiles the schema once
    • validate(json) : ValidationResult (valid + errors)
    • valid?(json) : Bool
    • close / finalize — frees the compiled schema
  • AtaValidator.validate(schema_json, json) : ValidationResult — one-shot
  • Ata.object Name do ... end — macro DSL (see above)
    • Name.valid?(json) : Bool, Name.validate(json) : ValidationResult, Name.schema_json : String, Name.from_json(json) : Name
  • Error types: CompileError (invalid schema), ValidationError (path, message)

Test

crystal run scripts/build_native.cr
crystal spec --link-flags "/LIBPATH:libata"

Benchmarks

bench/ compares ata-validator-crystal against JSON::Serializable and Athena::Validator on five scenarios: parse valid JSON, invalid JSON, nested objects, 100k bulk validation and per-operation allocation.

Results (2026-08-01, Windows 11 / MSVC, Crystal 1.15.0, --release --no-debug)

Higher is better for throughput, lower is better for allocation.

ScenarioMetricata-validator-crystalJSON::SerializableAthena::Validator× vs JSON::Serializable× vs Athena::Validator
Parse valid JSONops/s750 968449 970299 6401.67×2.51×
Parse valid JSONbytes/op328161 23225.5×38.5×
Invalid JSON (malformed)ops/s641 2871 1381 090563.5×588.3×
Invalid JSON (malformed)bytes/op1125 5045 50449.1×49.1×
Nested objectops/s338 270304 255190 0741.11×1.78×
Nested objectbytes/op321 0251 63232.0×51.0×
100.000 validationops/s844 921407 730274 9172.07×3.07×
100.000 validationbytes/op328161 23225.5×38.5×
Allocationbytes/op328161 23225.5×38.5×

Reading the ratios: for ops/s rows, = ata-validator-crystal is N× faster; for bytes/op rows, = ata-validator-crystal allocates N× less memory.

Note on "Invalid JSON": JSON::Serializable and Athena::Validator raise a JSON::ParseException on malformed JSON, so each iteration pays exception-handling cost (hence the ~1 000 ops/s and 5.5 kB/op). ata-validator-crystal returns valid=false with a structured error list instead of throwing, which is why it stays fast.

These numbers are machine- and schema-specific — rerun locally with crystal build bench/bench.cr --release --no-debug to reproduce.

# build with the shared fixtures (requires the dev dependency: shards install)
crystal build bench/bench.cr -o bin/bench.exe --release --no-debug --link-flags "/LIBPATH:libata"

# run everything (100k / 20k iterations per scenario)
$env:PATH = "$PWD\libata;$env:PATH"
.\bin\bench.exe

# run a subset, or override iterations
.\bin\bench.exe nested
.\bin\bench.exe 10000

Each scenario prints:

  • correctness — whether each target accepts/rejects each fixture
  • throughput — total ms and ops/s over N iterations
  • allocation — heap bytes per operation (GC.stats delta)

Adding a scenario

Drop a new file into bench/scenarios/ — it is auto-required. Register a row with Bench.register:

require "../framework"

Bench.register("My scenario", description: "...", order: 6) do |s|
  s.fixture("valid", %({"name": "Mert", "age": 28}))
  s.fixture("invalid", %({"age": -1}))

  s.target("my-tool") { |json| my_tool_valid?(json) }
end

Reuse the shared types (Person, PersonWithAddress, Bench::SCHEMA_*, Bench.person_workloads) from bench/support.cr, or define your own. The first fixture is used for the throughput/allocation runs.

License

MIT. The C++ core comes from ata-validator, MIT licensed (original copyright preserved in LICENSE).