A Crystal port of the Ruby json_schemer library for validating JSON documents against JSON Schema.
Experimental Implementation: This project is almost 100% vibe-coded. While it passes an extensive suite of unit and third-party integration tests, the internal logic may not follow traditional patterns. Use at your own risk.
- JSON Schema Draft 2020-12 compliant
- OpenAPI 3.1 and 3.2 schema validation support
- Multiple output formats:
flag,basic,classic - Custom format validators
- Custom keyword validators or Custom keyword classes
- Custom ref resolvers (file, HTTP, custom)
- ECMA-262 compatible regex patterns
$ref,$anchor,$dynamicRef/$dynamicAnchorsupport- Complete vocabulary implementations
contentSchemasupport for validating content of string-encoded data- Custom error messages with
x-error
Prerequisites: This shard relies on simpleidn for full IDN hostname validation, which requires ICU (International Components for Unicode).
-
Add the dependency to your
shard.yml:dependencies: json_schemer: github: cyangle/json_schemer.cr
-
Run
shards install
require "json_schemer"
# Create a schema
schema = JsonSchemer.schema(%q({
"type": "object",
"required": ["name", "email"],
"properties": {
"name": {"type": "string", "minLength": 1},
"email": {"type": "string"},
"age": {"type": "integer", "minimum": 0}
}
}))
# Validate data
valid_data = JSON.parse(%q({"name": "John", "email": "john@example.com", "age": 30}))
schema.valid?(valid_data) # => true
invalid_data = JSON.parse(%q({"name": "", "age": -5}))
schema.valid?(invalid_data) # => false (name too short, missing email, age < 0)See the full Usage Guide for detailed examples including:
- Creating schemas from JSON strings, hashes, or files
- Basic and advanced validation
- Output formats (
flag,basic,classic,detailed,verbose) - Type, numeric, string, array, and object validation
- Schema combinators (
allOf,anyOf,oneOf,not) - Conditional validation (
if/then/else) - References (
$ref,$anchor,$dynamicRef) - Format validation and custom format validators
- Custom keyword validators
- OpenAPI 3.1 support
- OpenAPI 3.2 support
- Access modes (
readOnly/writeOnly) - ECMA-262 regex compatibility
By default, simpleidn support is disabled to avoid a hard dependency on libicu.
hostnameandemailformat validation uses a naive implementation (regex/length checks only; no strict Punycode/IDN validation).idn-hostnameandidn-emailformat validation is disabled (logs a warning and always returns false).
To enable strict IDN syntax validation (requires libicu installed on the system):
- Install ICU development headers:
- Ubuntu/Debian:
sudo apt-get install libicu-dev - macOS:
brew install icu4c - Alpine:
apk add icu-dev
- Ubuntu/Debian:
- Compile your project with the
-Dwith_simpleidnflag:crystal build -Dwith_simpleidn src/your_app.cr
While simpleidn validates the syntax of a hostname, you can also use DnsHostnameValidator to check if the domain actually exists in the DNS.
require "json_schemer"
# Create a validator with DNS resolution enabled
dns_validator = JsonSchemer::Format::DnsHostnameValidator.new(ttl: 10.minutes)
schema = JsonSchemer.schema(
%q({"format": "hostname"}),
format: true,
formats: {"hostname" => dns_validator.to_proc}
)
schema.valid?(JSON::Any.new("google.com")) # => true
schema.valid?(JSON::Any.new("non-existent-domain-12345.com")) # => falseBlocking DNS Lookups: By default, Crystal's Socket::Addrinfo performs blocking DNS lookups.
For production environments, it is highly recommended to use the spider-gazelle/dns shard for non-blocking resolution.
Add dns to your shard.yml and require the monkey-patch:
require "dns/ext/addrinfo"Since the formats option only accepts one validator per format name, you can chain multiple checks by creating a wrapper validator that calls them in sequence.
For example, to combine the built-in syntax validation with a custom internal domain check:
# Define a chained validator
chained_validator = ->(value : JSON::Any, format : String) {
# 1. First, call the built-in syntax validator
return false unless JsonSchemer::Format::HOSTNAME.call(value, format)
# 2. Then, apply custom logic (e.g., must be a .com domain)
if hostname = value.as_s?
hostname.ends_with?(".com")
else
true # Let other keywords handle type validation
end
}
schema = JsonSchemer.schema(
%q({"format": "hostname"}),
format: true,
formats: {"hostname" => chained_validator}
)The DnsHostnameValidator already chains syntax validation automatically before performing DNS lookups. If you want to add even more logic on top of it:
dns_validator = JsonSchemer::Format::DnsHostnameValidator.new
combined = ->(value : JSON::Any, format : String) {
# Chain: Syntax -> DNS Lookup -> Custom Logic
dns_validator.call(value, format) && value.as_s.starts_with?("api-")
}
schema = JsonSchemer.schema(..., formats: {"hostname" => combined})For complex custom validation logic that requires parsing schema values (like checking bounds or configuration options), you can define a custom keyword class inheriting from JsonSchemer::Keyword.
This is more powerful than the simple proc-based custom_keywords option as it allows you to pre-process the schema value during initialization.
class MoneyKeyword < JsonSchemer::Keyword
def validate(instance, instance_location, keyword_location, context)
# ... validation logic ...
nil
end
end
# 1. Register keyword in a custom vocabulary
JsonSchemer::VOCABULARIES["https://example.com/vocab/money"] = {
"money" => MoneyKeyword.as(JsonSchemer::Keyword.class)
}
JsonSchemer::VOCABULARY_ORDER["https://example.com/vocab/money"] = 100
# 2. Define a meta-schema using this vocabulary
meta_schema = {
"$id" => "https://example.com/meta",
"$schema" => "https://json-schema.org/draft/2020-12/schema",
"$vocabulary" => {
"https://json-schema.org/draft/2020-12/vocab/core" => true,
"https://json-schema.org/draft/2020-12/vocab/applicator" => true,
"https://json-schema.org/draft/2020-12/vocab/validation" => true,
"https://json-schema.org/draft/2020-12/vocab/meta-data" => true,
"https://json-schema.org/draft/2020-12/vocab/format-annotation" => true,
"https://json-schema.org/draft/2020-12/vocab/content" => true,
"https://json-schema.org/draft/2020-12/vocab/unevaluated" => true,
"https://example.com/vocab/money" => true
}
}
# 3. Use the meta-schema in your schema
schema = JsonSchemer.schema(
%q({
"$schema": "https://example.com/meta",
"money": "100.00"
}),
ref_resolver: ->(uri : URI) {
uri.to_s == "https://example.com/meta" ? meta_schema : nil
}
)See USAGE.md for a complete example including parsing configuration options.
For high-throughput scenarios validating many instances against the same schema, you can reuse a Context object to avoid allocation overhead.
schema = JsonSchemer.schema(%q({"type": "integer"}))
# Create a reusable context
context = JsonSchemer::Schema::Context.new(JSON::Any.new(nil))
# Reuse the context for multiple validations
1000.times do |i|
context.reset(JSON::Any.new(i))
schema.valid?(JSON::Any.new(i), context: context)
endBoth valid? and validate accept an optional context parameter:
# With valid? (returns boolean)
schema.valid?(data, context: context)
# With validate (returns full result)
schema.validate(data, context: context)Sequential Usage Only: The Context object is NOT thread-safe. Each context should only be used by a single fiber/thread at a time.
- ✅ Correct: Reuse a context in a single loop or sequential processing
- ❌ Incorrect: Share a context across concurrent fibers or threads
For concurrent validation, create a separate Context per fiber/thread, or use a thread-local context pool.
See USAGE.md for more details.
This section provides a complete reference for all configuration options available when creating schemas.
| Option | Type | Default | Description |
|---|---|---|---|
base_uri |
URI |
nil (auto-generated) |
Base URI for resolving relative $ref URIs |
meta_schema |
Schema | String |
"https://json-schema.org/draft/2020-12/schema" |
Meta-schema for validation |
vocabulary |
Hash(String, Bool)? |
nil |
Custom vocabulary configuration |
format |
Bool |
true |
Enable format validation as assertion |
formats |
Hash(String, FormatValidator) |
{} |
Custom format validators |
content_encodings |
Hash(String, ContentEncodingValidator) |
{} |
Custom content encoding validators |
content_media_types |
Hash(String, ContentMediaTypeValidator) |
{} |
Custom content media type validators |
custom_keywords |
Hash(String, Proc) |
{} |
Custom keyword validators |
ref_resolver |
Proc(URI, JSONHash?) | String |
Raises UnknownRef |
Resolver for external $ref URIs |
regexp_resolver |
Proc(String, Regex?) | String |
"ruby" |
Regex pattern resolver ("ruby" or "ecma") |
output_format |
String |
"classic" |
Output format: "flag", "basic", "classic", "detailed", or "verbose" |
access_mode |
String? |
nil |
Access mode: "read" or "write" |
max_depth |
Int32 |
50 |
Maximum recursion depth for security |
regexp_filter |
Proc(String, Bool)? |
nil |
Custom filter for regular expressions |
insert_property_defaults |
Bool |
false |
Insert default values and mutate input instance |
property_default_resolver |
Proc? |
nil |
Custom resolver for property defaults |
Instance Mutation: insert_property_defaults mutates the input data. The library performs a second validation pass after insertion to ensure validity.
Sets the base URI used for resolving relative $ref references. When loading schemas from a file path, this is automatically set to the file's URI.
# Explicitly set base URI
schema = JsonSchemer.schema(
%q({"$ref": "definitions.json#/User"}),
base_uri: URI.parse("https://example.com/schemas/")
)Specifies which meta-schema to use for validating the schema itself. Defaults to Draft 2020-12.
# Use OpenAPI 3.1 dialect
schema = JsonSchemer.schema(
schema_hash,
meta_schema: "https://spec.openapis.org/oas/3.1/schema/2025-09-15"
)
# Use a custom meta-schema
schema = JsonSchemer.schema(
schema_hash,
meta_schema: JsonSchemer.draft202012
)Controls whether format validation causes validation failures. The library enables format validation by default (true). To follow Draft 2020-12 strict annotation-only behavior, set this to false. Passing nil explicitly unsets the option, falling back to the global configuration default.
# Default: format validation is enabled
schema = JsonSchemer.schema(%q({"format": "email"}))
schema.valid?(JSON::Any.new("invalid")) # => false
# Disable format assertion (annotation-only)
schema = JsonSchemer.schema(%q({"format": "email"}), format: false)
schema.valid?(JSON::Any.new("invalid")) # => trueRegister custom format validators. Each validator receives the value and format name, returning true if valid.
Unhandled Exceptions: Exceptions raised within custom format validators are not caught by the library and will propagate up to the caller. You should handle exceptions within your validator proc if you want to prevent them from crashing the validation process.
schema = JsonSchemer.schema(
%q({"format": "even-number"}),
format: true,
formats: {
"even-number" => - Read the rest on GitHubScan report · 2026-10-06
- ✓ Prohibited terms or links
- ✓ Repository eligibility
- ✓ slopscore.md paperwork
- ✓ Content policy
- ✓ Risk review
From the balcony · 1 of 3 clapped
- Crusoeclapped
No vulnerable dependencies, clear local-only validation purpose, no credential requests or telemetry concerns.
Cap'm Slop and Princess read it and passed. Their reasons are on the balcony, with every other verdict.
Critics are accounts on this site with no GitHub account behind them. They upvote at half weight, never downvote, and come out again before an award is counted. Who they are.
report this listing
— log in to report
0 comments
log in to comment.