Responses
Status, headers, cookies, redirects, JSON
# Responses
`TResponse` lives in the arena and its builders return `Self`, so they chain:
```pascal
Result := RespondJson(Payload, 201)
.WithHeader('Location', '/customers/7');
```
## Constructors
| | |
|---|---|
| `Respond(Status)` | Empty, status 200 by default |
| `RespondText(S, Status)` | `text/plain; charset=utf-8` |
| `RespondHtml(S, Status)` | `text/html; charset=utf-8` |
| `RespondJson(S, Status)` | `application/json` |
| `Redirect(Location, Status)` | 302 by default |
| `NoContent` | 204 |
| `Problem(Status, Detail)` | `application/problem+json`, RFC 9457 |
```pascal
Result := RespondText('hello');
Result := RespondJson('{"ok":true}');
Result := Redirect('/customers', 303);
Result := NoContent;
Result := Problem(409, 'That order has already shipped.');
```
`Problem` is the shape errors take for a client that is not a browser, and
the framework's own 404, 405, 419, 401 and 500 use it when the caller
asked for JSON. See [APIs](api.md).
## Answering with a status from deep inside
Raising is the only way out of the middle of a function, and not every
failure is a fault. An exception descending from `EHttpError` says which
status it should become:
```pascal nocheck
type
ENotFound = class(EHttpError)
public
function HttpStatus: Integer; override; { 404 }
function PublicDetail: string; override; { optional }
end;
```
The server answers with that instead of 500, and does not log it as a
failure when it is below 500 — a refused request is not a fault, and a
connection is not closed over one.
**The exception's message still does not reach the client.**
`PublicDetail` is empty by default, for the same reason `detail` in a
problem document is: the message is where the SQL, the path and the value
are. Overriding it is how an application says something on purpose.
`EForbidden`, from [gates](auth.md#gates), is the one Askr ships: 403,
with nothing added.
## Headers
```pascal
Res.WithHeader('X-Request-Id', Id); { last value wins for that name }
Res.AddHeader('Set-Cookie', Raw); { appends }
Res.HeaderValue('Content-Type'); { read it back }
```
`WithHeader` lets the last value win per name. Two `Location` or two
`Content-Type` headers is almost always a caller bug, and for those it is
actively harmful.
`Set-Cookie` is the exception that may legitimately repeat, which is what
`AddHeader` and `WithCookie` are for.
## Cookies
```pascal
Res.WithCookie('theme', 'dark', 86400);
```
```pascal nocheck
function WithCookie(const AName, AValue: string;
MaxAge: Integer = -1;
Secure: Boolean = False;
ReadableByJs: Boolean = False;
const SameSite: string = 'Lax';
const Path: string = '/'): TResponse;
```
`HttpOnly` and `SameSite=Lax` are the default because the alternative is
remembering them. `ReadableByJs` turns `HttpOnly` off for the cookies a
frontend genuinely must read — `XSRF-TOKEN` is the one that matters.
`MaxAge` below zero gives a session cookie; zero deletes.
> Until `WithCookie` existed, the session cookie and the CSRF cookie
> overwrote each other, because `WithHeader` let the last value win. Two
> cookies in one response is exactly why it was added.
## Status codes
```pascal nocheck
Res.Status(422);
Res.StatusCode;
```
`StatusText` knows the usual codes plus **419 Page Expired**, which is not
in any RFC. It is a convention the Inertia client recognises: it reloads
the page instead of showing an error. That is the right behaviour for an
expired CSRF token — usually the user left a tab open, not an attack.
## Redirect status matters
**After `PUT`, `PATCH` or `DELETE`, redirect with 303.** With 302 the
browser repeats the method against the new address.
```pascal
Result := Redirect('/customers', 303);
```
## Conditional GET
```pascal
Respond(200).WithBody(Html).WithETag('v3')
```
The value is the opaque part **without quotes** — they are added for you,
because an unquoted ETag is not a valid one and the mistake stays invisible
until some client rejects it. `WithETag(Value, True)` makes it weak.
The comparison happens in the server, once, so no handler has to do it: a
`GET` or `HEAD` carrying `If-None-Match` that matches becomes a **304 with
no body**. Static files get an ETag automatically, from the file's
modification time and size.
**A response that sets a cookie never answers 304, and loses its ETag.**
A body that comes with a cookie is a body made for one client. A page with
a CSRF token in it, served from the client's cache on a later 304, is a
form whose token has since been rotated — a rejected submit that nobody
can reproduce. The guard is in the framework rather than in a rule you have
to remember, and it takes the ETag with it: leaving the tag would only move
the problem to the next cache in the chain.
Only `GET` and `HEAD` are conditional this way. `If-None-Match` on other
methods is a precondition — answered with `412`, which Askr does not do —
and turning a `POST` into a 304 would answer a write with "your copy is
current" and drop it.
### What is not here
**No `Last-Modified` or `If-Modified-Since`.** ETags answer the same
question with fewer ways to be subtly wrong, and a date has one-second
resolution with a timezone attached.
**The static ETag has a one-second window.** It is built from modification
time and size, as nginx and Apache build theirs, so a file rewritten within
the same second at the same length keeps its tag. Hashing the bytes would
close that window at the cost of a pass over every file on every request;
the window closes itself on the next write.
## Bodies
```pascal nocheck
Res.WithBody('text');
Res.WithBody(SomeTStr); { no copy }
Res.Body;
```
A `TStr` body is written straight out. A `string` body is copied into the
arena.
`BodyForbidden` is true for the statuses that by definition have no body
(204, 304, 1xx). `HEAD` keeps `Content-Length` and drops the body, as the
protocol requires.
## JSON
```pascal
uses Askr.Core.Json;
W.Init(Arena, 1024);
W.BeginObject;
W.Field('id', C.Id);
W.Field('name', C.Name);
W.Key('orders');
W.BeginArray;
...
W.EndArray;
W.EndObject;
Result := RespondJson(W.ToString);
```
`TJsonWriter` writes into the arena and tracks nesting, so a mismatched
`EndObject` is caught rather than producing broken JSON.
Models serialise through RTTI in `Askr.Urd.Json`:
```pascal
Result := RespondModel(Customer);
```
## Reading a response
Useful in response filters and tests:
```pascal nocheck
Res.StatusCode;
Res.HeaderValue('Location');
Res.HeaderCount;
Res.HeaderAt(I); { for the several Set-Cookie case }
Res.Body.ToString;
```