No description
Find a file
2026-09-21 20:22:33 +03:00
spec refactor validatable, fix booleans 2026-09-21 20:22:33 +03:00
src refactor validatable, fix booleans 2026-09-21 20:22:33 +03:00
.editorconfig v 0.1.0 2026-09-21 11:43:11 +03:00
.gitignore v 0.1.0 2026-09-21 11:43:11 +03:00
LICENSE v 0.1.0 2026-09-21 11:43:11 +03:00
README.md refactor validatable, fix booleans 2026-09-21 20:22:33 +03:00
shard.yml refactor validatable, fix booleans 2026-09-21 20:22:33 +03:00

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

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

  1. Add a check_* helper in src/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
  1. Pass the annotation parameter in the __validate_value macro in src/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, not from_json. JSON::Serializable raises on the first type mismatch and stops, which is incompatible with "collect every error". The DTO is constructed with from_json only at the very end, once the types are known to be correct.
  • The field loop lives in a module instance method, not in macro finished or inside macro included. In Crystal 1.21, @type.instance_vars is only resolvable while the including type is instantiated, which is why this is the same trick as Mappable#copy_from.
  • UUID bridge. UUID is not JSON-serializable in the standard library; src/schemer/uuid.cr adds UUID.new(JSON::PullParser) and UUID#to_json. This is a documented monkey-patch, applied whenever Schemer is required.
  • Soft failures are values. validate returns a Result; exceptions are reserved for require and for value! misuse.

Limitations

  • Multi-type unions (String | Int32) are unsupported and fail at compile time.
  • in is supported for String only.
  • Time, enums, and nested Hash(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.