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.

| Contract | Value |
| --- | --- |
| Release | 0.15.0 |
| Needs | An action with a contract (see [Routes and contracts](https://caramelize.dev/docs/0.16.0/routes-and-contracts/index.md)) |
| 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](https://caramelize.dev/cookbook/0.16.0/return-json/index.md) shows one action answering a browser and a JSON client. This page lists every response and limit.

## Limits at a glance {#c-actions-limits}

| 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 {#c-actions-shape}

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**

```crystal
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 |

**app/actions/books/create.cr · handle and render**

```crystal
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 →](https://caramelize.dev/docs/0.16.0/routes-and-contracts/index.md)

## Accept and HX-Request choose the client {#c-actions-negotiation}

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 |

[Read and write JSON →](https://caramelize.dev/cookbook/0.16.0/return-json/index.md)

## Pages render inside the layout {#c-actions-pages}

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

**Inside an action**

```crystal
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.

**app/actions/application_action.cr · generated**

```crystal
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 {#c-actions-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**

```crystal
def handle(contract : Contract)
  count = 3
  morph "#count", with: markup { span { count.to_s } }
end
```

**Inside an action · the contract declares field name : String**

```crystal
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
```

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

[Write the HTML →](https://caramelize.dev/docs/0.16.0/views/index.md)

## Redirects check their target {#c-actions-redirects}

| 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` |

**Inside an action**

```crystal
def handle(contract : Contract)
  redirect_external("https://example.com/docs", 301)
end
```

## A stream writes its body from a block {#c-actions-stream}

`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**

```crystal
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 →](https://caramelize.dev/docs/0.16.0/cold-brew/index.md)

## Invalid input answers 422 {#c-actions-errors}

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 |

**POST /items with seats=0&extra=1 · Accept: */*, with a valid CSRF token and Origin**

```markdown
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 →](https://caramelize.dev/docs/0.16.0/sweettalk/index.md). A generated resource with a form re-renders it with the submitted values and every error:

**app/actions/books.cr · generated, inside module Form**

```crystal
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 {#c-actions-json}

`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 →](https://caramelize.dev/docs/0.16.0/sugarorm/index.md)

## The session is a signed cookie {#c-actions-request}

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

**Inside an action · the contract declares field user_id : String**

```crystal
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 →](https://caramelize.dev/docs/0.16.0/security/index.md)[Receive a signed webhook →](https://caramelize.dev/cookbook/0.16.0/webhooks/index.md)

Prove it

## Check the answers with Corretto {#c-actions-test}

These commands assume the Book resource of [Your first application](https://caramelize.dev/docs/0.16.0/getting-started/index.md), step 02; generate it first.

**Terminal · inside the application**

```bash
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 →](https://caramelize.dev/docs/0.16.0/corretto/index.md)

[← Routes and contracts](https://caramelize.dev/docs/0.16.0/routes-and-contracts/index.md) · [Write the HTML →](https://caramelize.dev/docs/0.16.0/views/index.md)

Caramel 0.16.0 · https://caramelize.dev/docs/0.16.0/actions-and-responses/
