{"title":"Validation and Migration","description":"Automatic schema enforcement, response contracts, and gradual V1 migration","section":"guides/schema-api","version":"v2","path":"guides/schema-api/validation","canonical_url":"https://amberframework.org/docs/v2/guides/schema-api/validation","markdown_url":"https://amberframework.org/docs/v2/guides/schema-api/validation.md","inherited":false,"content_markdown":"# Validation and migration\n\n> **Released in `2.0.0-beta.5`:** automatic action enforcement and the HTML\n> failure hook are available now. The deprecated validator remains functional\n> for backwards compatibility.\n\nA schema bound with `schema :action, SchemaClass` runs before user callbacks and\nbefore the controller action. Application code does not call a manual\n`SchemaClass.validate(request)` method, and no extra route keyword is required.\n\nThis distinction matters: the same declarations that generate documentation\nare the declarations Amber enforces at runtime.\n\n## Where the examples go\n\n- Put reusable contracts under `src/schemas/`.\n- Put action bindings, `validated_as` or `validated_params` reads, response\n  declarations, and the HTML failure hook inside the matching controller under\n  `src/controllers/`.\n- The short typed-value and closed-contract fragments on this page belong\n  inside those schema or controller files; they are not terminal commands.\n- Put request-level acceptance examples under `spec/controllers/` and isolated\n  contract examples under `spec/schemas/`.\n\n## Request enforcement\n\n**File: `src/controllers/pets_controller.cr`.**\n\n```crystal\nclass PetsController < ApplicationController\n  schema :create, CreatePetSchema\n\n  def create\n    input = validated_as(CreatePetSchema)\n\n    Pet.create!(\n      name: input.name.not_nil!,\n      species: input.species.not_nil!,\n      age: input.age\n    )\n  end\nend\n```\n\nAmber performs this request-local sequence before `create` runs:\n\n1. verify the request media type against the schema;\n2. parse the body and collect declared path, query, header, and cookie values;\n3. coerce each declared value once;\n4. apply required, range, length, enum, format, pattern, nested, conditional,\n   and cross-field rules;\n5. expose the same normalized values to constraints, typed getters, and the\n   controller action.\n\nIf validation fails, the action does not run. Amber writes the structured error\nresponse and stops the controller callback path.\n\n## Keep HTML form failures as HTML\n\nThe default failure body is JSON because controller schemas commonly protect\nAPI boundaries. A server-rendered controller can override one hook without\ngiving up automatic enforcement.\n\n**File: `src/controllers/pets_controller.cr` — add this inside\n`PetsController`.**\n\n```crystal\nprotected def handle_schema_validation_failure(\n  action : Symbol,\n  result : Amber::Schema::LegacyResult,\n) : Nil\n  @errors = result.errors\n  error = result.errors.first?\n  response.status_code = error.is_a?(Amber::Schema::RequestParseError) ? error.http_status : 422\n  response.content_type = \"text/html\"\n\n  case action\n  when :create\n    @pet = Pet.new\n    context.content = render(\"new.ecr\")\n  when :update\n    if pet = Pet.find(params[:id])\n      @pet = pet\n      context.content = render(\"edit.ecr\")\n    else\n      redirect_to \"/pets\"\n    end\n  else\n    super\n  end\nend\n```\n\nSetting `context.content` gives Amber the complete rendered response and keeps\nthe action from running. New CLI HTML scaffolds use this pattern so an invalid\nform returns an ECR page with its field errors; API controllers keep the JSON\ndefault.\n\n## Typed values and the normalized hash\n\n```crystal\ninput = validated_as(CreatePetSchema)\nname = input.name.not_nil! # String\nage = input.age            # Int32?\n\nvalues = validated_params.not_nil!\nraw_name = values[\"name\"] # JSON::Any\n```\n\n`validated_as` verifies that the request-local schema has the expected class.\nIt never returns a schema object shared by another request. The field metadata\nis shared, while data, errors, and typed values remain request-local.\n\nAfter a schema succeeds, the controller's existing `params` helper prioritizes\nthe normalized schema values and falls back to raw params for undeclared keys.\nThat bridge supports action-by-action migration without changing unrelated\ncontroller code.\n\n## Response enforcement\n\n**File: `src/controllers/pets_controller.cr` — declare the response above the\naction and use the schema-aware `respond_with` inside it.**\n\n```crystal\nresponse_schema :create,\n  PetResponseSchema,\n  status: 201,\n  description: \"Pet created\"\n\ndef create\n  input = validated_as(CreatePetSchema)\n  pet = Pet.create!(name: input.name.not_nil!)\n\n  respond_with({\n    \"id\"   => JSON::Any.new(pet.id),\n    \"name\" => JSON::Any.new(pet.name),\n  }, status: 201)\nend\n```\n\nBefore serialization, Amber validates the response shape and exact declared\nstatus. A mismatch becomes a 500 contract error. If the client asks for a\nformat the response schema does not declare, Amber returns 406.\n\nUse the regular controller `respond_with` blocks for HTML, JSON, XML, text,\nJavaScript, or Markdown pages that do not use a response schema. The schema\nversion accepts `Hash(String, JSON::Any)`, a named tuple, or `nil` so it can\nvalidate and encode the response contract.\n\n## Open and closed contracts\n\nSchemas accept undeclared fields by default for backwards compatibility.\nChoose a closed contract deliberately:\n\n```crystal\nclass CreatePetSchema < Amber::Schema::Definition\n  additional_properties false\n\n  field :name, String, required: true\nend\n```\n\nWith that line, an undeclared request-body field produces 422 and an\nundeclared response field produces the response-contract 500. Without it,\nundeclared fields remain available in the normalized data.\n\n## Failure statuses\n\n| Status | Contract boundary |\n|---|---|\n| `400` | Malformed JSON, CBOR, or COSE document |\n| `406` | Undeclared response representation |\n| `415` | Undeclared request representation |\n| `422` | Parsed values fail the request schema |\n| `500` | Response shape or status fails its schema |\n| `503` | COSE input is valid in principle, but no key provider is configured |\n\nThe error response includes a stable code, the affected field when applicable,\nand validation details. Do not turn a malformed document into a 422 or a\nwell-formed invalid document into a 400; the distinction helps clients correct\nthe right layer.\n\n## Migrate the deprecated validator gradually\n\nExisting V1-style code continues to compile and run in Amber V2:\n\n**File: the existing action under `src/controllers/` — unchanged legacy code.**\n\n```crystal\nvalidation = params.validation do\n  required(:email) { |value| value.email? }\nend\n```\n\nThe compiler emits a deprecation warning because new code should use an\nexecutable controller schema. The warning does not mean the API was removed in\nV2.0. Amber plans no removal before a later V2 minor such as 2.5, and the exact\nrelease will be announced separately.\n\nUse this order:\n\n1. change the Amber version and verify the existing application first;\n2. define one action's request schema under `src/schemas/`;\n3. bind it with `schema :action, SchemaClass`;\n4. replace reads with `validated_as(SchemaClass)` or `validated_params`;\n5. add `response_schema` to API actions whose output should be enforced;\n6. run the action's request specs and the complete application suite;\n7. repeat for the next action.\n\nThe source-compatible `validate_schema` and `auto_validate` declarations may\nremain during the migration, but they no longer control enforcement: a bound\nschema always runs automatically.\n\n## Test both sides of the contract\n\nFor every bound action, keep request specs that prove at least:\n\n- one valid request reaches the action;\n- malformed input returns 400;\n- an unsupported request content type returns 415;\n- valid syntax with invalid values returns 422;\n- each supported response media type is negotiated correctly;\n- an unsupported `Accept` value returns 406; and\n- a deliberately invalid response fails closed with 500.\n\nFor COSE endpoints, also test a valid inbound envelope, authentication failure,\nan unknown key ID, key rotation, and behavior when configuration is absent."}