- Crystal 100%
| spec | ||
| src | ||
| .editorconfig | ||
| .gitignore | ||
| LICENSE | ||
| README.md | ||
| shard.yml | ||
Schemer
Declarative validation and unpacking of incoming JSON payloads, built only on
the Crystal standard library (JSON, JSON::Serializable, UUID).
The core is framework-agnostic (src/schemer.cr + src/schemer/). The
optional HTTP glue lives separately in src/schemer/contrib/kemal.cr so the
core can be extracted into its own shard without carrying a web framework along.
- Quick start
- The two entry points
- How it works
- Rule reference
- Supported field types
- Errors
- Nested objects and arrays
- Custom validation
- Strict mode
- Kemal integration
- Extending the validator
- Design notes
- Limitations
- Extracting into a shard
Quick start
Add the mixin to a JSON::Serializable type and put rules in the
Schemer::Validate annotation on the getters:
require "./schemer"
struct PostCreate
include JSON::Serializable
include Schemer::Validatable
@[Schemer::Validate(required: true, min_length: 1, max_length: 200)]
getter title : String
@[Schemer::Validate(required: true, min_length: 1)]
getter body : String
@[Schemer::Validate(max_items: 10, each: {max_length: 32})]
getter tags : Array(String) = [] of String
end
Then, in a handler:
body = env.request.body || "{}"
# soft API
result = PostCreate.validate(body)
if result.valid?
payload = result.value! # PostCreate
else
halt env, status_code: 400, response: {"errors" => result.errors}.to_json
end
# or hard API, let the exception handler own the response
payload = PostCreate.require(body) # PostCreate or raises Schemer::Failed
validate never raises on bad input. require raises Schemer::Failed
carrying every error. Exactly one of them is what a given handler needs.
The two entry points
Both accept a String, an IO, or an already parsed JSON::Any.
def self.validate(raw : String | IO | JSON::Any,
path : String = "",
strict : Bool = false) : Schemer::Result(self)
def self.require(raw : String | IO | JSON::Any,
path : String = "",
strict : Bool = false) : self
validate |
require |
|
|---|---|---|
| invalid input | returns Result with errors |
raises Schemer::Failed |
| valid input | result.value! |
returns the DTO directly |
| use when | you render the 400 yourself | a middleware/error handler renders it |
Result:
result.valid? # Bool
result.value # T?
result.value! # T, raises if the result is invalid (a programmer error)
result.errors # Array(Schemer::Error)
How it works
One call walks the payload exactly once and collects every error; the DTO is built only at the end, when the field types are already known to be correct.
raw: String | IO | JSON::Any
|
+- JSON.parse (only for String/IO) -- JSON::ParseException --> malformed_json
|
v
JSON::Any -- not an object --> type: "payload must be a JSON object"
|
v
__validate(json, path, errors, strict)
|
+- for each declared field:
| +- absent or null --> required (unless nilable or has a default)
| `- present --> __validate_value(raw, fpath, ...)
| |
| +- String/Bool/Int/Float/UUID --> check_* --> validate_with
| +- nested Validatable --> __validate (recurse, path "author.city")
| `- Array(T) --> __validate_value per element, path "tags[2]"
|
+- strict? --> unknown_key for undeclared keys
|
v
errors.empty?
|
+- no --> Result(nil, errors)
`- yes --> from_json --> Result(value)
The field loop and the array loop share one macro, __validate_value, so a
type or a rule behaves identically whether it sits on a field or on an array
element.
Rule reference
All parameters are optional. An omitted parameter means "no constraint".
| Parameter | Applies to | Meaning | Error code |
|---|---|---|---|
required |
any | key must be present and non-null | required |
min_length / max_length |
String, Array element |
length bounds | min_length / max_length |
pattern |
String |
must match a regex literal | pattern |
in |
String |
must be one of the listed values | inclusion |
min / max |
integers, floats | numeric bounds | min / max |
max_items |
Array |
maximum number of elements | max_items |
each |
Array |
a nested rule set applied to every element | element's code |
validate_with |
scalar, scalar array element | name of a custom class method to call | whatever it emits |
strict: (call argument) |
whole type | reject keys not declared on the type | unknown_key |
Example using most of them:
@[Schemer::Validate(
required: true,
min_length: 1,
max_length: 64,
pattern: /^[a-z0-9-]+$/,
in: ["draft", "published"],
validate_with: "check_slug"
)]
getter slug : String
Note that pattern takes a regex literal, not a string. in takes an
array literal. validate_with takes the method name, as either a string
("check_slug") or a symbol (:check_slug).
Supported field types
| Crystal type | JSON | Notes |
|---|---|---|
String |
string | |
Bool |
bool | |
Int8 … Int64 |
integer | range of the concrete type is enforced |
Float32, Float64 |
number | an integer is accepted and widened |
UUID |
string | parsed with UUID.parse? |
Array(T) |
array | T is String, Bool, an integer, a float, UUID, or a validatable struct |
nested include Schemer::Validatable |
object | validated recursively |
T? |
any / null | absent or null passes unless required |
| field with a default | any | absent key keeps the default |
A field that is neither nilable nor has a default is implicitly required even
without required: true, because JSON::Serializable would reject it anyway.
Any other type (a multi-type union, Time, enums, …) fails at compile time
with a clear message, so an unsupported field can never silently skip
validation. Add support in Extending the validator.
Errors
Each error is Schemer::Error(field, code, message):
record Error, field : String, code : String, message : String
field is a path into the payload: title, author.address.city,
tags[2]. code is the stable machine contract; message is human-facing.
Rendered as JSON:
{"errors":[
{"field":"title","code":"required","message":"is required"},
{"field":"author.name","code":"min_length","message":"length must be >= 1"},
{"field":"tags[1]","code":"type","message":"must be a string"}
]}
Codes: required, type, malformed_json, unknown_key, min_length,
max_length, pattern, inclusion, min, max, max_items, custom.
Every error in a single pass is collected — validation never stops at the first failure, and a type error on one field does not hide business errors on another.
Nested objects and arrays
Any nested JSON::Serializable type that also includes
Schemer::Validatable is validated recursively, and paths are prefixed:
struct Address
include JSON::Serializable
include Schemer::Validatable
@[Schemer::Validate(required: true, min_length: 1)]
getter city : String
end
struct Author
include JSON::Serializable
include Schemer::Validatable
@[Schemer::Validate(required: true)]
getter name : String
@[Schemer::Validate(required: true)]
getter address : Address
end
A missing city is reported as author.address.city. Arrays of nested types
report authors[0].name:
@[Schemer::Validate(max_items: 20)]
getter authors : Array(Author) = [] of Author
Custom validation
validate_with names a class method on the same type (a class method, so
it cannot accidentally read an uninitialized sibling field):
struct UserCreate
include JSON::Serializable
include Schemer::Validatable
@[Schemer::Validate(validate_with: "check_handle")]
getter handle : String = ""
# Signature: (already type-checked value, path, errors)
def self.check_handle(value : String, path : String, errors : Array(Schemer::Error)) : Nil
if RESERVED.includes?(value)
errors << Schemer::Error.new(path, "custom", "handle is reserved")
end
end
RESERVED = ["admin", "root", "system"]
end
The method receives the value after its built-in type check, so
value is already the typed Crystal value (String, Int64, Float64,
Bool, or UUID). It is only called when the type check passed. A
validate_with method can be shared by declaring it in a module and
extending it (which makes the methods class methods):
module Handles
def check_handle(value : String, path : String, errors : Array(Schemer::Error)) : Nil
errors << Schemer::Error.new(path, "custom", "reserved") if value == "admin"
end
end
struct UserCreate
include JSON::Serializable
include Schemer::Validatable
extend Handles
@[Schemer::Validate(validate_with: "check_handle")]
getter handle : String = ""
end
Strict mode
By default, unknown keys are ignored, matching JSON::Serializable. Pass
strict: true to report them:
result = PostCreate.validate(body, strict: true)
# extra keys -> Error(field: "extra", code: "unknown_key", message: "unknown field")
Strict mode recurses into nested objects and arrays.
Kemal integration
src/schemer/contrib/kemal.cr defines Schemer::Handler, a plain
HTTP::Handler that rescues Schemer::Failed and renders it as a 422 JSON
error response.
Register it on the Kemal config somewhere before Kemal.run:
require "schemer/contrib/kemal"
Kemal.config.add_handler(Schemer::Handler.new) # before Kemal.run
# ... later, in a route:
post "/posts/" do |env|
payload = REQUESTS::PostCreate.require(env.request.body || "{}")
# ... on failure the handler above already answered with 422 and errors
env.response.status_code = 201
DATABASE.post_create_with_tags(payload.title, payload.body, payload.tags).to_json
end
The handler depends only on http/server, not on Kemal, so any
HTTP::Handler-based server can install it the same way.
If you use validate instead, the route owns the response and the handler is
irrelevant:
result = REQUESTS::PostCreate.validate(env.request.body || "{}")
unless result.valid?
halt env, status_code: 400, response: {"errors" => result.errors}.to_json
end
Extending the validator
Add a rule
- Add a
check_*helper insrc/schemer/checks.cr:
def self.check_string(value : String, field : String, errors : Array(Error), *,
# ... existing args ...
uppercase : Bool? = nil) : Nil
if uppercase && value != value.upcase
errors << Error.new(field, "uppercase", "must be uppercase")
end
end
- Pass the annotation parameter in the
__validate_valuemacro insrc/schemer/validatable.cr:
Schemer.check_string(v, {{fpath}}, {{errors}},
min_length: {{ rules ? rules[:min_length] : nil }},
uppercase: {{ rules ? rules[:uppercase] : nil }})
Add a supported type
In src/schemer/validatable.cr, add a branch to the __validate_value macro.
A scalar type looks like the UUID branch: extract from raw, call a helper,
emit a type error on failure. The branch covers both fields and array
elements, since both go through the same macro. Nested objects need no branch
of their own: they are detected with base.has_method?(:__validate).
Add a whole new shape of rule
validate_with is the escape hatch that does not require touching the core:
any rule expressible as a function of one value can live in your application
code. Reach for core changes only for new types or rules that need
compile-time knowledge of the field type.
Cross-field rules (compare two fields) are not part of v1. If needed, add an
overridable after_validate(errors) instance hook called after the DTO is
constructed, and keep field-level rules as they are.
Design notes
- Schemer walks
JSON::Any, notfrom_json.JSON::Serializableraises on the first type mismatch and stops, which is incompatible with "collect every error". The DTO is constructed withfrom_jsononly at the very end, once the types are known to be correct. - The field loop lives in a module instance method, not in
macro finishedor insidemacro included. In Crystal 1.21,@type.instance_varsis only resolvable while the including type is instantiated, which is why this is the same trick asMappable#copy_from. - UUID bridge.
UUIDis not JSON-serializable in the standard library;src/schemer/uuid.craddsUUID.new(JSON::PullParser)andUUID#to_json. This is a documented monkey-patch, applied wheneverSchemeris required. - Soft failures are values.
validatereturns aResult; exceptions are reserved forrequireand forvalue!misuse.
Limitations
- Multi-type unions (
String | Int32) are unsupported and fail at compile time. inis supported forStringonly.Time, enums, and nestedHash(String, X)are unsupported.- No coercion:
"5"is not accepted for an integer field. - No cross-field rules in v1 (see above).
Extracting into a shard
The core has no dependency on Kemal, the database, or anything in crylog:
src/schemer.cr
src/schemer/error.cr
src/schemer/result.cr
src/schemer/checks.cr
src/schemer/uuid.cr
src/schemer/validatable.cr
src/schemer/contrib/kemal.cr # optional HTTP::Handler, requires "http/server"
To publish, move those files into a repository with shard.yml name
schemer, with src/schemer.cr as the entry point. Consumers then
require "schemer" and, if they want the 422 handler,
require "schemer/contrib/kemal".
The Schemer namespace and the public surface (validate, require,
Result, Error, Failed, the Validate annotation, check_* helpers) are
already self-contained.