Answer every client.
How an action turns a valid contract into a page, a fragment, a redirect, a stream or JSON in Caramel 0.7.1.
Write an action
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
endhandle returns a Caramel::Response or a result. A response passes through unchanged. Any other result goes to a JSON client as JSON, and to render(result) for everyone else. A result without a render fails compilation.
status starts at 200. Setting it 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 →
Know which client is asking
Caramel weighs only application/json and text/html in Accept. A q value is clamped to 0 through 1; one that is not a finite number weighs 0.
- JSON client: rates
application/jsonabovetext/htmland does not sendHX-Request: true. - Browser: sends
HX-Request: true, givestext/htmla weight above 0, or sends noAcceptat all. - Neither: such as
Accept: */*alone. Results still go torender; errors answer as plain text.
htmx always asks for HTML, so an htmx request never receives JSON. Pages, fragments, JSON and streams carry Vary: Accept, HX-Request, HX-Request-Type and Cache-Control: no-store. Pages and fragments also set the CSRF cookie; JSON does not.
Render pages
page(title, body, status) takes a String, a view, or markup { … }. A String body is trusted HTML and is not escaped. markup escapes like a view, and the action’s own methods stay available inside it.
def handle(contract : Contract)
page "Shelf", markup { h1 { "Shelf" } }
endA request with HX-Request-Type: partial receives only a <title> and the body; htmx removes the title before swapping. Every other request receives 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
endUpdate regions with htmx
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"),
])
endA partial’s html is trusted. Its swap defaults to innerMorph and must be one of innerHTML, outerHTML, innerMorph, outerMorph, beforebegin, afterbegin, beforeend, afterend, delete or none. A target holds 1 to 256 bytes. Anything else raises ArgumentError.
Redirect or answer not found
redirect_to(path)answers 303 withLocation, or 200 withHX-Locationfor htmx. The path starts with/, not//, and holds no backslash or control character.redirect_external(url, status = 302)sends the browser to another site. The status is 301, 302, 303, 307 or 308. The URL is absolute http or https with a host, and has no credentials, whitespace, control characters or backslashes. htmx gets 200 withHX-Redirect, so it navigates instead of fetching.not_found(message = "Not found")answers 404 with the message as its body.
A path or URL that breaks these rules raises ArgumentError. Redirects carry Vary: HX-Request and Cache-Control: no-store.
def handle(contract : Contract)
redirect_external("https://example.com/docs", 301)
endStream a body
stream(content_type, status) writes the body from its block. Uploaded files are already deleted when the block runs. A HEAD request gets the headers without running it.
Caramel::SSE.write(io, data, event) writes one server-sent event and flushes it. 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.
def handle(contract : Contract)
stream "text/event-stream" do |io|
3.times { |tick| Caramel::SSE.write(io, event: "Tick", data: tick.to_s) }
end
endA client that disconnects ends the stream quietly. Any other error in the block comes after the status line, so Caramel can only log it.
Publish updates with Cold Brew →
Answer errors
An invalid contract never reaches handle. A JSON client gets 422 with {"errors": {…}}, an array of messages per field. A browser gets a 422 page titled Check your request that lists each field and message. Any other client gets plain text:
ERR CONTRACT_INVALID:422 at POST /items
FIELD seats: must be at least 1
FIELD _base: Unknown field: extraFor errors found after the contract, such as a changeset’s, return render_errors(errors, status = 422). It answers each client the same way, with your status; its text starts with ERR INVALID: and the status.
Override contract_failure_page(contract) to change the browser page. 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)
endRead the request and the session
requestThe HTTP::Request being answeredcsrf_tokenThe token for forms the action renderssessionThe signed session, a Hash(String, String)sign_outClears the sessiondef handle(contract : Contract)
session["user_id"] = contract.user_id
redirect_to("/")
endThe session lives in the __Host-caramel_session cookie, signed with HMAC-SHA256. It has no server-side expiry and lasts until the browser session ends or the application clears it. Caramel saves it only when it changed; an empty session deletes the cookie. A session whose cookie would pass 4096 bytes raises Caramel::Session::Overflow, so keep ids there, not records. A route whose ingress sets csrf: false reads an empty session and never saves it.