Answer every client.
How an action turns a valid contract into a page, a fragment, a redirect, a stream or JSON in Caramel 0.15.0.
| Contract | Value |
|---|---|
| Release | 0.15.0 |
| Needs | An action with a contract (see Routes and contracts) |
| Covers | Pages, fragments, redirects, streams, JSON, errors and the session |
| Proved by | frappe check · a Corretto request spec |
Learning it for the first time? The recipe Read and write JSON shows one action answering a browser and a JSON client. This page lists every response and limit.
Limits at a glance
| Limit | Value |
|---|---|
q weight in Accept, clamped | 0–1 |
| Partial target length | 1–256 bytes |
Default redirect_external status | 302 |
An action answers one request
An action is a struct that answers one request. It subclasses your application’s App::ApplicationAction, which subclasses Caramel::Action. contract do … end declares the request’s typed fields. handle(contract : Contract) runs only when the contract is valid.
module App::Books
struct Show < App::ApplicationAction
contract do
field id : Int64, min: 1
end
def handle(contract : Contract)
record = App::Book.query.find(contract.id)
return not_found("Book not found") unless record
{record: record}
end
def render(result)
page "Book", Views::Books::Show.new(result[:record], csrf_token)
end
end
end| Condition | Outcome |
|---|---|
handle returns a Caramel::Response | passes through unchanged |
| Any other result, JSON client | answers as JSON |
| Any other result, everyone else | goes to render(result) |
A result without a render | fails compilation |
status before handle sets it | is 200 |
status set in handle | changes the JSON status and the default status of page, morph, partials and stream |
| Redirects | keep their own status |
def handle(contract : Contract)
changes = App::Book.create(title: contract.title)
return render_errors(changes.errors) unless changes.saved?
self.status = 201
{record: changes.record}
end
def render(result)
redirect_to(book_path(result[:record].id))
endA JSON client gets 201 and the record. A browser gets a 303 to the new book.
Declare routes and contracts →
Accept and HX-Request choose the client
Caramel weighs only application/json and text/html in Accept.
| Condition | Outcome |
|---|---|
Accept rates application/json above text/html, and no HX-Request: true | is a JSON client |
HX-Request: true, a text/html weight above 0, or no Accept at all | is a browser |
Neither, such as Accept: */* alone | sends results to render and answers errors as plain text |
A q outside 0 through 1 | is clamped |
A q that is not a finite number | weighs 0 |
| An htmx request | never receives JSON; htmx always asks for HTML |
| Pages, fragments, JSON and streams | carry Vary: Accept, HX-Request, HX-Request-Type and Cache-Control: no-store |
| Pages and fragments | set the CSRF cookie; JSON does not |
Pages render inside the layout
page(title, body, status) takes a String, a view, or markup { … }.
def handle(contract : Contract)
page "Shelf", markup { h1 { "Shelf" } }
end| Condition | Outcome |
|---|---|
A String body | is trusted HTML and is not escaped |
A view or markup { … } body | is escaped; the action’s own methods stay available inside markup |
HX-Request-Type: partial | answers only a <title> and the body; htmx removes the title before swapping |
| Any other request | answers layout(page), the full document |
HX-Request: true alone | still gets the full document, so history restores, body swaps and hx-select keep the layout |
Caramel::Action has a minimal default layout with an escaped title. The generated base action replaces it with your layout view and suffixes every title.
module App
abstract struct ApplicationAction < Caramel::Action
include App::Paths
def layout(page : Caramel::Page) : String
Views::Layouts::Application.new(page, csrf_token).to_s
end
def title_for(page : Caramel::Page) : String
"#{page.title} · #{App::TITLE}"
end
end
endmorph and partials update regions
morph(target, with: html, swap: "innerMorph") replaces one target’s content. partials sends several Caramel::Partial records in one response. Each becomes an <hx-partial> element that htmx 4 swaps into its own target.
def handle(contract : Contract)
count = 3
morph "#count", with: markup { span { count.to_s } }
enddef handle(contract : Contract)
members = ["Ann", contract.name]
partials([
Caramel::Partial.new("#roster", markup { ul { members.each { |m| li { m } } } }.to_s),
Caramel::Partial.new("#roster-count", members.size.to_s, "innerHTML"),
])
end| Condition | Outcome |
|---|---|
A partial’s html | is trusted |
| A partial with no swap | uses innerMorph |
A swap other than innerHTML, outerHTML, innerMorph, outerMorph, beforebegin, afterbegin, beforeend, afterend, delete, none | raises ArgumentError |
| A target outside 1 to 256 bytes | raises ArgumentError |
Redirects check their target
| Condition | Outcome |
|---|---|
redirect_to(path) | answers 303 with Location |
redirect_to(path) from htmx | answers 200 with HX-Location |
A redirect_to path that does not start with /, starts with //, or holds a backslash or control character | raises ArgumentError |
redirect_external(url, status = 302) | sends the browser to another site |
redirect_external from htmx | answers 200 with HX-Redirect, so it navigates instead of fetching |
A redirect_external status other than 301, 302, 303, 307 or 308 | raises ArgumentError |
| A URL that is not absolute http or https with a host, or has credentials, whitespace, control characters or backslashes | raises ArgumentError |
not_found(message = "Not found") | answers 404 with the message as its body |
| Redirects | carry Vary: HX-Request and Cache-Control: no-store |
def handle(contract : Contract)
redirect_external("https://example.com/docs", 301)
endA stream writes its body from a block
stream(content_type, status) writes the body from its block.
Caramel::SSE.write(io, data, event) writes one server-sent event and flushes it.
def handle(contract : Contract)
stream "text/event-stream" do |io|
3.times { |tick| Caramel::SSE.write(io, event: "Tick", data: tick.to_s) }
end
end| Condition | Outcome |
|---|---|
| Uploaded files | are already deleted when the block runs |
| A HEAD request | gets the headers without running the block |
Each line of data | becomes its own data: field, so the browser receives the text unchanged |
| An event name with a line break | raises ArgumentError |
| A client that disconnects | ends the stream quietly |
| Any other error in the block (it comes after the status line) | ends the stream and is reported to Crema as unhandled, with source: "stream" |
Publish updates with Cold Brew →
Invalid input answers 422
An invalid contract never reaches handle.
| Condition | Outcome |
|---|---|
| Invalid contract, JSON client | answers 422 with {"errors": {…}}, an array of messages per field |
| Invalid contract, browser | answers a 422 page titled Check your request that lists each field and message |
| Invalid contract, any other client | answers 422 as plain text |
render_errors(errors, status = 422) for errors found after the contract, such as a changeset’s | answers each client the same way with your status; its text starts with ERR INVALID: and the status |
A SweetTalk refusal that handle does not rescue (with caramel/sweet_talk) | answers 422 with its message under _base, by render_refusal(contract, refusal) |
A SweetTalk::Missing that handle does not rescue | answers 404 by not_found |
Override of contract_failure_page(contract) | changes the browser page |
ERR CONTRACT_INVALID:422 at POST /items
FIELD seats: must be at least 1
FIELD _base: Unknown field: extraWhat each client gets for a refusal is in the SweetTalk guide: Call the domain from actions →. A generated resource with a form re-renders it with the submitted values and every error:
def contract_failure_page(contract : Caramel::RequestContract) : Caramel::Response
render_form(contract.values, contract.errors, contract.values["id"]?.try(&.to_i64?), 422)
endJSON answers may carry a version
json(value, status = 200) answers a value as JSON. self.etag = version in handle adds the version to the answer, and if_match?(version) reads the request’s If-Match header against it.
| Condition | Outcome |
|---|---|
json(value) | answers 200 with value.to_json; json(value, status) uses your status |
self.etag = version, then a JSON answer below 300 | adds ETag: "version"; pages, fragments and redirects never carry it |
if_match?(version) with no If-Match header, with *, or with "version" among its entries | returns true |
if_match?(version) with another version, or a weak tag such as W/"3" | returns false; the action decides the answer, such as 412 |
A versioned update that answers 409 and 412 is application code; the SugarORM reference shows one.
Read how saves return a changeset →The session is a signed cookie
| Call | Returns |
|---|---|
request | The HTTP::Request being answered |
csrf_token | The token for forms the action renders |
session | The signed session, a Hash(String, String) |
sign_out | Clears the session |
def handle(contract : Contract)
session["user_id"] = contract.user_id
redirect_to("/")
end| Condition | Outcome |
|---|---|
A session that handle changed | sends Set-Cookie with the response |
| A session whose cookie is over the limit | raises Caramel::Session::Overflow |
Security defaults lists the cookie, its signature, its lifetime, the limit and the csrf: false case.
Review the security defaults →Receive a signed webhook →
Prove it
Check the answers with Corretto
These commands assume the Book resource of Your first application, step 02; generate it first.
frappe check
frappe corretto spec/requests/books_spec.crExpect: frappe check exits 0 when every result that is not a Caramel::Response has a render; a result without one fails compilation. frappe corretto runs the generated request spec and ends with Corretto: 1 of 1 workers passed.
Passing: The generated spec is green: a browser form gets a 303 to the new book, Accept: application/json on its page gets 200 and the record, a forged token or a foreign Origin gets 403, and a stray field under HX-Request-Type: partial gets 422 with Unknown field: unexpected_field.