Documentation

Validation and Migration

New
Browse documentation

Published 2026-08-13. V2 is a prerelease; the web core is release-gated and other previews are named separately. What beta means.

Read this page as HTML, Markdown, or structured JSON—or open the published Markdown with an AI assistant. Gemini receives the prompt through your clipboard because its signed-out page does not reliably prefill URL text; paste when the new tab opens. External assistants need the public site URL.

1.4.1
Unavailable in this version
1.5
Unavailable in this version

Validation and migration

Released in 2.0.0-beta.5: automatic action enforcement and the HTML failure hook are available now. The deprecated validator remains functional for backwards compatibility.

A schema bound with schema :action, SchemaClass runs before user callbacks and before the controller action. Application code does not call a manual SchemaClass.validate(request) method, and no extra route keyword is required.

This distinction matters: the same declarations that generate documentation are the declarations Amber enforces at runtime.

Where the examples go

  • Put reusable contracts under src/schemas/.
  • Put action bindings, validated_as or validated_params reads, response declarations, and the HTML failure hook inside the matching controller under src/controllers/.
  • The short typed-value and closed-contract fragments on this page belong inside those schema or controller files; they are not terminal commands.
  • Put request-level acceptance examples under spec/controllers/ and isolated contract examples under spec/schemas/.

Request enforcement

File: src/controllers/pets_controller.cr.

Crystal
class PetsController < ApplicationController
  schema :create, CreatePetSchema

  def create
    input = validated_as(CreatePetSchema)

    Pet.create!(
      name: input.name.not_nil!,
      species: input.species.not_nil!,
      age: input.age
    )
  end
end

Amber performs this request-local sequence before create runs:

  1. verify the request media type against the schema;
  2. parse the body and collect declared path, query, header, and cookie values;
  3. coerce each declared value once;
  4. apply required, range, length, enum, format, pattern, nested, conditional, and cross-field rules;
  5. expose the same normalized values to constraints, typed getters, and the controller action.

If validation fails, the action does not run. Amber writes the structured error response and stops the controller callback path.

Keep HTML form failures as HTML

The default failure body is JSON because controller schemas commonly protect API boundaries. A server-rendered controller can override one hook without giving up automatic enforcement.

File: src/controllers/pets_controller.cr — add this inside PetsController.

Crystal
protected def handle_schema_validation_failure(
  action : Symbol,
  result : Amber::Schema::LegacyResult,
) : Nil
  @errors = result.errors
  error = result.errors.first?
  response.status_code = error.is_a?(Amber::Schema::RequestParseError) ? error.http_status : 422
  response.content_type = "text/html"

  case action
  when :create
    @pet = Pet.new
    context.content = render("new.ecr")
  when :update
    if pet = Pet.find(params[:id])
      @pet = pet
      context.content = render("edit.ecr")
    else
      redirect_to "/pets"
    end
  else
    super
  end
end

Setting context.content gives Amber the complete rendered response and keeps the action from running. New CLI HTML scaffolds use this pattern so an invalid form returns an ECR page with its field errors; API controllers keep the JSON default.

Typed values and the normalized hash

Crystal
input = validated_as(CreatePetSchema)
name = input.name.not_nil! # String
age = input.age            # Int32?

values = validated_params.not_nil!
raw_name = values["name"] # JSON::Any

validated_as verifies that the request-local schema has the expected class. It never returns a schema object shared by another request. The field metadata is shared, while data, errors, and typed values remain request-local.

After a schema succeeds, the controller's existing params helper prioritizes the normalized schema values and falls back to raw params for undeclared keys. That bridge supports action-by-action migration without changing unrelated controller code.

Response enforcement

File: src/controllers/pets_controller.cr — declare the response above the action and use the schema-aware respond_with inside it.

Crystal
response_schema :create,
  PetResponseSchema,
  status: 201,
  description: "Pet created"

def create
  input = validated_as(CreatePetSchema)
  pet = Pet.create!(name: input.name.not_nil!)

  respond_with({
    "id"   => JSON::Any.new(pet.id),
    "name" => JSON::Any.new(pet.name),
  }, status: 201)
end

Before serialization, Amber validates the response shape and exact declared status. A mismatch becomes a 500 contract error. If the client asks for a format the response schema does not declare, Amber returns 406.

Use the regular controller respond_with blocks for HTML, JSON, XML, text, JavaScript, or Markdown pages that do not use a response schema. The schema version accepts Hash(String, JSON::Any), a named tuple, or nil so it can validate and encode the response contract.

Open and closed contracts

Schemas accept undeclared fields by default for backwards compatibility. Choose a closed contract deliberately:

Crystal
class CreatePetSchema < Amber::Schema::Definition
  additional_properties false

  field :name, String, required: true
end

With that line, an undeclared request-body field produces 422 and an undeclared response field produces the response-contract 500. Without it, undeclared fields remain available in the normalized data.

Failure statuses

Status Contract boundary
400 Malformed JSON, CBOR, or COSE document
406 Undeclared response representation
415 Undeclared request representation
422 Parsed values fail the request schema
500 Response shape or status fails its schema
503 COSE input is valid in principle, but no key provider is configured

The error response includes a stable code, the affected field when applicable, and validation details. Do not turn a malformed document into a 422 or a well-formed invalid document into a 400; the distinction helps clients correct the right layer.

Migrate the deprecated validator gradually

Existing V1-style code continues to compile and run in Amber V2:

File: the existing action under src/controllers/ — unchanged legacy code.

Crystal
validation = params.validation do
  required(:email) { |value| value.email? }
end

The compiler emits a deprecation warning because new code should use an executable controller schema. The warning does not mean the API was removed in V2.0. Amber plans no removal before a later V2 minor such as 2.5, and the exact release will be announced separately.

Use this order:

  1. change the Amber version and verify the existing application first;
  2. define one action's request schema under src/schemas/;
  3. bind it with schema :action, SchemaClass;
  4. replace reads with validated_as(SchemaClass) or validated_params;
  5. add response_schema to API actions whose output should be enforced;
  6. run the action's request specs and the complete application suite;
  7. repeat for the next action.

The source-compatible validate_schema and auto_validate declarations may remain during the migration, but they no longer control enforcement: a bound schema always runs automatically.

Test both sides of the contract

For every bound action, keep request specs that prove at least:

  • one valid request reaches the action;
  • malformed input returns 400;
  • an unsupported request content type returns 415;
  • valid syntax with invalid values returns 422;
  • each supported response media type is negotiated correctly;
  • an unsupported Accept value returns 406; and
  • a deliberately invalid response fails closed with 500.

For COSE endpoints, also test a valid inbound envelope, authentication failure, an unknown key ID, key rotation, and behavior when configuration is absent.