caramel
Skip to content
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.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.

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

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

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 →

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/json above text/html and does not send HX-Request: true.
  • Browser: sends HX-Request: true, gives text/html a weight above 0, or sends no Accept at all.
  • Neither: such as Accept: */* alone. Results still go to render; 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.

Read and write JSON →

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.

Inside an action
def handle(contract : Contract)
  page "Shelf", markup { h1 { "Shelf" } }
end

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

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

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

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

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

Write the HTML →

Redirect or answer not found

  • redirect_to(path) answers 303 with Location, or 200 with HX-Location for 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 with HX-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.

Inside an action
def handle(contract : Contract)
  redirect_external("https://example.com/docs", 301)
end

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

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

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

POST /items with seats=0&extra=1 · Accept: */*
ERR CONTRACT_INVALID:422 at POST /items
FIELD seats: must be at least 1
FIELD _base: Unknown field: extra

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

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

Read the request and the session

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

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

Receive a signed webhook →