ata-validator-crystal
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(orlibata.a) — at link time, passed tocrystal buildvia/LIBPATH(Windows/MSVC) or-L(Unix)ata.dll(orlibata.so) — at runtime, next to the executable, onPATH, or pointed to byATA_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
Define schemas with Ata.object instead of writing raw JSON Schema. Every field is required unless optional: true is passed; the block can call the builder directly or take a |b| argument.
User = Ata.object 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)
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"]}
| Method | Arguments | JSON Schema |
|---|---|---|
string |
min / max → minLength / maxLength, pattern, format, values: → enum |
{"type": "string", ...} |
int |
gt / lt → exclusiveMinimum / exclusiveMaximum, gte / lte → minimum / maximum |
{"type": "integer", ...} |
float |
same as int |
{"type": "number", ...} |
bool |
— | {"type": "boolean"} |
any |
— | {} (any value accepted) |
array |
of: :string/:int/:float/:bool/:any or a nested Ata.object schema; min_items / max_items |
{"type": "array", "items": {...}} |
object |
of: a nested Ata.object schema |
embeds the schema as-is |
Nested schemas compose:
Address = Ata.object do
string :city, min: 1
int :zip, gte: 0
end
Person = Ata.object 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 : StringAtaValidator::Validator.new(schema_json)— compiles the schema oncevalidate(json) : ValidationResult(valid+errors)valid?(json) : Boolclose/finalize— frees the compiled schema
AtaValidator.validate(schema_json, json) : ValidationResult— one-shotAta.object { ... } : Ata::Schema— schema DSL (see above)Schema#valid?(json) : Bool,Schema#validate(json) : ValidationResult,Schema#schema_json : String
- 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.
| Scenario | Metric | ata-validator-crystal | JSON::Serializable | Athena::Validator | × vs JSON::Serializable | × vs Athena::Validator |
|---|---|---|---|---|---|---|
| Parse valid JSON | ops/s | 750 968 | 449 970 | 299 640 | 1.67× | 2.51× |
| Parse valid JSON | bytes/op | 32 | 816 | 1 232 | 25.5× | 38.5× |
| Invalid JSON (malformed) | ops/s | 641 287 | 1 138 | 1 090 | 563.5× | 588.3× |
| Invalid JSON (malformed) | bytes/op | 112 | 5 504 | 5 504 | 49.1× | 49.1× |
| Nested object | ops/s | 338 270 | 304 255 | 190 074 | 1.11× | 1.78× |
| Nested object | bytes/op | 32 | 1 025 | 1 632 | 32.0× | 51.0× |
| 100.000 validation | ops/s | 844 921 | 407 730 | 274 917 | 2.07× | 3.07× |
| 100.000 validation | bytes/op | 32 | 816 | 1 232 | 25.5× | 38.5× |
| Allocation | bytes/op | 32 | 816 | 1 232 | 25.5× | 38.5× |
Reading the ratios: for
ops/srows,N×= ata-validator-crystal is N× faster; forbytes/oprows,N×= ata-validator-crystal allocates N× less memory.
Note on "Invalid JSON":
JSON::SerializableandAthena::Validatorraise aJSON::ParseExceptionon malformed JSON, so each iteration pays exception-handling cost (hence the ~1 000 ops/s and 5.5 kB/op).ata-validator-crystalreturnsvalid=falsewith 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.statsdelta)
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).
ata-validator-crystal
- 1
- 0
- 0
- 0
- 1
- about 2 hours ago
- August 1, 2026
MIT License
Sat, 01 Aug 2026 17:26:19 GMT