Skip to content
carameldocs · 0.16.0
Browse documentation
Reference / Actions and responses

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.

ContractValue
Release0.15.0
NeedsAn action with a contract (see Routes and contracts)
CoversPages, fragments, redirects, streams, JSON, errors and the session
Proved byfrappe 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

LimitValue
q weight in Accept, clamped0–1
Partial target length1–256 bytes
Default redirect_external status302

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.

app/actions/books/show.cr · generated by frappe make resource Book
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
ConditionOutcome
handle returns a Caramel::Responsepasses through unchanged
Any other result, JSON clientanswers as JSON
Any other result, everyone elsegoes to render(result)
A result without a renderfails compilation
status before handle sets itis 200
status set in handlechanges the JSON status and the default status of page, morph, partials and stream
Redirectskeep their own status
app/actions/books/create.cr · handle and render
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))
end

A 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.

ConditionOutcome
Accept rates application/json above text/html, and no HX-Request: trueis a JSON client
HX-Request: true, a text/html weight above 0, or no Accept at allis a browser
Neither, such as Accept: */* alonesends results to render and answers errors as plain text
A q outside 0 through 1is clamped
A q that is not a finite numberweighs 0
An htmx requestnever receives JSON; htmx always asks for HTML
Pages, fragments, JSON and streamscarry Vary: Accept, HX-Request, HX-Request-Type and Cache-Control: no-store
Pages and fragmentsset the CSRF cookie; JSON does not

Read and write JSON →

Pages render inside the layout

page(title, body, status) takes a String, a view, or markup { … }.

Inside an action
def handle(contract : Contract)
  page "Shelf", markup { h1 { "Shelf" } }
end
ConditionOutcome
A String bodyis trusted HTML and is not escaped
A view or markup { … } bodyis escaped; the action’s own methods stay available inside markup
HX-Request-Type: partialanswers only a <title> and the body; htmx removes the title before swapping
Any other requestanswers layout(page), the full document
HX-Request: true alonestill 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.

app/actions/application_action.cr · generated
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
end

morph 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.

Inside an action
def handle(contract : Contract)
  count = 3
  morph "#count", with: markup { span { count.to_s } }
end
Inside an action · the contract declares field name : String
def 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
ConditionOutcome
A partial’s htmlis trusted
A partial with no swapuses innerMorph
A swap other than innerHTML, outerHTML, innerMorph, outerMorph, beforebegin, afterbegin, beforeend, afterend, delete, noneraises ArgumentError
A target outside 1 to 256 bytesraises ArgumentError

Write the HTML →

Redirects check their target

ConditionOutcome
redirect_to(path)answers 303 with Location
redirect_to(path) from htmxanswers 200 with HX-Location
A redirect_to path that does not start with /, starts with //, or holds a backslash or control characterraises ArgumentError
redirect_external(url, status = 302)sends the browser to another site
redirect_external from htmxanswers 200 with HX-Redirect, so it navigates instead of fetching
A redirect_external status other than 301, 302, 303, 307 or 308raises ArgumentError
A URL that is not absolute http or https with a host, or has credentials, whitespace, control characters or backslashesraises ArgumentError
not_found(message = "Not found")answers 404 with the message as its body
Redirectscarry Vary: HX-Request and Cache-Control: no-store
Inside an action
def handle(contract : Contract)
  redirect_external("https://example.com/docs", 301)
end

A 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.

Inside an action
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
ConditionOutcome
Uploaded filesare already deleted when the block runs
A HEAD requestgets the headers without running the block
Each line of databecomes its own data: field, so the browser receives the text unchanged
An event name with a line breakraises ArgumentError
A client that disconnectsends 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.

ConditionOutcome
Invalid contract, JSON clientanswers 422 with {"errors": {…}}, an array of messages per field
Invalid contract, browseranswers a 422 page titled Check your request that lists each field and message
Invalid contract, any other clientanswers 422 as plain text
render_errors(errors, status = 422) for errors found after the contract, such as a changeset’sanswers 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 rescueanswers 404 by not_found
Override of contract_failure_page(contract)changes the browser page
POST /items with seats=0&extra=1 · Accept: */*, with a valid CSRF token and Origin
ERR CONTRACT_INVALID:422 at POST /items
FIELD seats: must be at least 1
FIELD _base: Unknown field: extra

What 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:

app/actions/books.cr · generated, inside module Form
def contract_failure_page(contract : Caramel::RequestContract) : Caramel::Response
  render_form(contract.values, contract.errors, contract.values["id"]?.try(&.to_i64?), 422)
end

JSON 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.

ConditionOutcome
json(value)answers 200 with value.to_json; json(value, status) uses your status
self.etag = version, then a JSON answer below 300adds ETag: "version"; pages, fragments and redirects never carry it
if_match?(version) with no If-Match header, with *, or with "version" among its entriesreturns 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

CallReturns
requestThe HTTP::Request being answered
csrf_tokenThe token for forms the action renders
sessionThe signed session, a Hash(String, String)
sign_outClears the session
Inside an action · the contract declares field user_id : String
def handle(contract : Contract)
  session["user_id"] = contract.user_id
  redirect_to("/")
end
ConditionOutcome
A session that handle changedsends Set-Cookie with the response
A session whose cookie is over the limitraises 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.

Terminal · inside the application
frappe check
frappe corretto spec/requests/books_spec.cr

Expect: 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.

Test requests with Corretto →