Every input, declared.
Map each method and path to one action, and declare the typed fields that action accepts.
Declare the routes
All routes live in one Caramel::Router.draw block in config/routes.cr. Each line names a verb, a path and an action. The verbs are get, post, put, patch and delete.
module App
Caramel::Router.draw do
get "/", App::Home::Show
get "/books", App::Books::Index
get "/books/new", App::Books::New
post "/books", App::Books::Create
get "/books/:id", App::Books::Show
patch "/books/:id", App::Books::Update
delete "/books/:id", App::Books::Destroy
# Frappé resource routes
end
endA path starts with /. Static segments use letters, digits and ._~-. A parameter is :snake_case, such as :id, and appears once per path. A path has at most 32 segments.
Let the compiler check the table
The route table is checked when the application compiles. Each of these stops the build:
- a line that is not one of the five verbs with a string path and an action constant, such as
resources :books; - a malformed path, a repeated parameter or more than 32 segments;
- an action that is undefined, does not inherit from
Caramel::Action, or has nocontract do … endblock (an empty one is fine); - a parameter without a contract field of the same name;
- a parameter field that is not a non-nilable
String,Int32orInt64withoutdefault:; - two routes that match the same requests (
DUPLICATE ROUTE); - a static route declared after a parameter route that would catch it (
AMBIGUOUS ROUTE ORDER). Declare/books/newbefore/books/:id.
A missing field names the route, the contract block and the fix:
❌ ROUTE CONTRACT MISMATCH
Route: '/books/:book_id' defines parameter ':book_id'
Action: 'App::Books::Show::Contract' is missing 'field book_id : Type'
--> config/routes.cr:9:5
Contract: app/actions/books/show.cr:3:5
Remediation: add `field book_id : Int64` to the contract block of App::Books::Show.Know what a request matches
Static segments win over parameters, and matching backtracks. GET /books/new reaches New, and GET /teams/new/members can still reach /teams/:team_id/members. Empty segments never match a parameter, so /books/ is not /books.
- No route at the path answers 404 with
Not found. - A route at the path with another method answers 405 with
Method not allowedand anAllowheader, such asGET, HEAD, PATCH. - HEAD runs the GET route and drops the body. It keeps the headers and sets
Content-Length, except on streamed responses. - A route parameter that fails its field answers 404. With
field id : Int64, min: 1, both/books/abcand/books/0are not found. - A path over 32 segments answers 404. A malformed path escape answers 400.
HTML forms can only send GET and POST. A POST form with a _method field of PATCH, PUT or DELETE, in any case, is routed as that method. Any other value answers 400. The override is refused with 405 when the target route reads its body differently, for example through its own ingress. JSON bodies do not override; send the real method.
Declare the contract
Every action declares the fields it accepts. A field is a name, a type and optional bounds. handle runs only when every field is valid, and reads them as typed getters.
contract do
field title : String, min: 1, max: 200
field pages : Int32?, min: 1
field price : Float64, min: 0
field published : Bool, default: false
field released_at : Time?
field cover : Caramel::UploadedFile?
endStringText as sent; Caramel does not trim itInt32, Int64Digits with an optional signFloat64A finite decimal, with an optional exponentBoolExactly true or falseTimeRFC 3339 with seconds and a zone, such as 2026-09-30T08:00:00Z; read as UTCCaramel::UploadedFileA multipart file part: filename, content_type, path and sizeAdd ? for a field that may be absent; its getter returns nil. default: fills an absent field. min: and max: bound a String’s length in characters, and the value of an Int32, Int64 or Float64. Bounds on other types, defaults on files and any other type fail compilation.
An absent value, or text that is only whitespace, is missing. A missing field takes its default, stays nil when nilable, and is an error otherwise. An unchecked checkbox sends nothing, so give its Bool field default: false.
Bind each value from one source
A field binds by name from the route parameters, the body or the query string. The body is a URL-encoded form, a multipart form or a JSON object. There is no precedence between sources: a name sent twice, in one source or across two, is an error. _csrf and _method in a form are transport controls, never fields.
Every submitted key must be a declared field. GET and HEAD queries are the exception, so links can carry tracking or cache-busting parameters. On other methods, an unknown query key is an error too.
A JSON member must have its field’s JSON type: a string for String and Time, a number for the numeric types, and a boolean for Bool. null counts as absent. Numbers keep their source text, so 3.5 is not a valid Int32. A body that is not an object is an error. In JSON, _csrf and _method are ordinary members: JSON clients send X-CSRF-Token and the real method.
Requests the router cannot read answer before the contract: 400 Malformed request for broken encoding or JSON, 413 Request body is too large past the route’s limit, and 415 for any other content type. The default limit is 2 MiB of body text; files share a 64 MiB budget. An action’s ingress declaration changes the limit, keeps raw bytes, or adds an authenticator that answers 401 before the contract binds.
Read the contract errors
Errors collect per field name. Errors about the request as a whole go under _base. These are the English messages:
is requiredA missing field with no default that is not nilablemust be a valid Int32Text that does not convert; also Int64, Float64, Bool and Timemust be a JSON stringA JSON member of the wrong type; also number and booleanmust be at least 3 charactersA String under min:, or over max: with must be at mostmust be at least 1A number under min:, or over max: with must be at mostmust be a fileText sent for an UploadedFile fieldDuplicate field: titleUnder _base: a name sent more than onceUnknown field: extraUnder _base: a key the contract does not declareExpected a JSON objectUnder _base: a JSON body that is an array or a scalarA failed contract answers 422, shaped for the client. A client that prefers JSON receives {"errors": {"page": ["must be a valid Int32"]}}. A browser or htmx request receives a page titled Check your request that lists each error; generated form actions override contract_failure_page to re-render the form. Other clients receive plain text:
ERR CONTRACT_INVALID:422 at GET /books
FIELD _base: Duplicate field: pageWith caramel/i18n, these messages follow the request’s language.
Link with path helpers
Caramel.resource_paths in config/paths.cr defines four helpers. App::Paths is included in ApplicationAction and ApplicationView, so actions and views can call them.
module App::Paths
Caramel.resource_paths :books, :book
# Frappé resource paths
endbooks_path/booksbook_path(id : Int64)/books/12new_book_path/books/newedit_book_path(id : Int64)/books/12/editAn ID must be positive, or the helper raises ArgumentError. With locale prefixes enabled, each helper adds the request’s prefix.
Inspect and repair routes
frappe routes lists every route with its action and contract. A filter keeps routes whose method, path or action contains it, in any case. Routes with their own ingress end with a bracketed summary, such as [raw, 256 KiB, csrf off, authenticate signed?].
GET /books/:id App::Books::Show id:Int64(min=1)
PATCH /books/:id App::Books::Update id:Int64(min=1) title:Stringfrappe check runs the type check and prints MRDP unless stdout is a terminal. A route without its field becomes a CONTRACT_MISMATCH at the contract block, with a PATCH line. Inserting that line makes the check pass. A field of the wrong type gets a FIX line instead.
ERR CONTRACT_MISMATCH:422 at app/actions/shelves/show.cr:3:5
NODE: RequestContract
MISSING: id:Int64
PATCH: INSERT "field id : Int64" AT 4:7