응답
WebResponse는 불변 값이다.
모든 메서드는 새 응답을 반환하므로, after 필터는 라우트가 반환한 응답을 다시 쓸 수 있다.
따라서 쓰지 않고 버린 반환값은 잃어버린 변경이다.
WebResponse에는 Error Prone의 @CheckReturnValue가 붙어 있으므로, Error Prone과 IntelliJ는 그런 호출을 컴파일 시점에 지적한다.
불변성은 봉투에 적용된다: 상태, 헤더, 쿠키, 그리고 템플릿 모델의 항목이다.
쿠키는 추가될 때 복사되고 cookies()가 넘겨줄 때 다시 복사되므로, 이후에 Cookie 객체를 바꿔도 응답이 보내는 것은 아무것도 바뀌지 않는다.
템플릿 모델은 불변인 Model이므로, 핸들러가 모델을 만든 뒤에 하는 어떤 일도 페이지에 도달하지 않는다.
복사는 얕은 복사이며, 모델 값으로 저장된 가변 객체는 여전히 핸들러가 넘긴 바로 그 객체이다.
bytes 본문의 배열과 stream 본문의 라이터는 복사되지 않고 그대로 넘겨지는데, 두 본문은 큰 본문을 한 번 더 복사하지 않으려고 존재하기 때문이다.
json 본문의 값도 복사되지 않고 그대로 들고 있다가 답을 쓸 때 읽히므로, 핸들러가 반환한 뒤에 바꾼 값은 답을 바꾼다.
본문
html, text, rawJson(text), json(JsonValue), json(value, writer), bytes(contentType, data), template(name), template(name, model), stream(contentType, out → …), file(path), sse(stream → …), raw((req, res) → …).
콘텐츠 타입을 받는 모든 본문에서 콘텐츠 타입은 첫 번째 인자이다.
템플릿 이름에는 확장자가 없다.
템플릿에서 설명하듯이, 엔진이 자신의 확장자를 덧붙인다.
body()는 봉인된 WebResponse.Body를 반환한다: Empty, Text, Bytes, Json, Template, Stream, Sse, 또는 Raw이다.
이 타입에 대한 switch에는 default 케이스가 필요 없으며, 테스트는 답을 읽어낼 서블릿 응답 없이도 답을 검증할 수 있다.
WebResponse.json은 값과 그 라이터를 든 Json으로 답하고, 그 toJson()이 테스트가 읽을 문서를 쓴다:
JsonObject requestBody = Json.object().put("name", "Spanish");
WebResponse response = controller.createDeck(TestRequest.post("/api/decks")
.jsonBody(requestBody)
.build());
assertThat(response.status()).isEqualTo(HttpStatus.CREATED);
assertThat(response.header("Location")).isEqualTo("/api/decks/1");
assertThat(((WebResponse.Json<?>) response.body()).toJson()).isEqualTo("{\"id\":1}");
서블릿은 모든 필터가 답한 뒤, 예외 처리가 아직 적용되는 동안 Json 본문을 쓰므로, 예외를 던지는 라이터는 예외 핸들러에 도달한다.
서블릿은 문서를 스레드가 간직하는 버퍼에 쓰고, Content-Length 뒤에 그 버퍼를 보내고, 버퍼를 돌려주므로, 클라이언트로 가는 길에 문서 크기의 배열이 할당되지 않는다.
버퍼는 서블릿 밖으로 나가지 않으므로, 상수로 둔 응답이나 필터가 들고 있는 응답은 그것이 답하는 요청마다 다시 쓰인다.
body(replacement)는 모든 헤더를 보존한다.
Content-Length, ETag, Last-Modified, Content-Encoding을 포함하여, 이전 본문을 설명하는 메타데이터는 직접 갱신하거나 제거해야 한다.
타입이 바뀌면 Content-Type을 대체 본문의 타입으로 설정한다.
예를 들어, 본문을 평문으로 대체하는 필터는 이전 메타데이터를 제거한다:
return response.body(new WebResponse.Text(replacement))
.contentType("text/plain; charset=UTF-8")
.withoutHeader("Content-Length")
.withoutHeader("ETag")
.withoutHeader("Last-Modified")
.withoutHeader("Content-Encoding");
길이가 설정되지 않았을 때 라이터는 텍스트, 바이트, JSON에 대해 새 길이를 계산한다. 스트리밍 대체 본문은 길이를 생략해도 된다.
디스크의 파일
file(path)는 파일 이름이 함의하는 콘텐츠 타입, Content-Length에 담은 파일 크기, 그리고 Stream 본문으로 답한다.
app.get("/decks/{deckId}/export", req ->
WebResponse.file(service.writeExport(req.pathParamLong("deckId")))
.attachment("deck.csv"));
타입은 확장자에서 오며, 테이블이 모르는 확장자는 application/octet-stream으로 답한다.
파일 이름이 내용에 대해 쓸모 있는 정보를 주지 않을 때는 .contentType(…)으로 타입을 지정하며, 이 값이 이름에서 알아낸 타입을 덮어쓴다.
테이블 자체는 공개되지 않는다: 애플리케이션은 파일로 답할 때만 이 테이블을 쓴다.
attachment(name)은 Content-Disposition을 설정하므로, 브라우저는 응답을 그 이름으로 저장한다.
헤더 값은 전송될 때 ISO-8859-1이며, 따옴표는 따옴표로 감싼 이름을 일찍 끝낸다.
따라서 출력 가능한 ASCII만으로 되지 않았거나 따옴표나 역슬래시를 담은 이름은 "와 \를 이스케이프한 ASCII 대체 이름과 함께, 퍼센트 인코딩한 UTF-8인 RFC 6266의 filename*로도 나간다.
현재 쓰이는 모든 브라우저가 filename*을 먼저 읽으므로, attachment("덱.csv")는 덱.csv로 저장된다.
이름에 줄바꿈을 포함한 제어 문자가 있으면 IllegalArgumentException을 던진다.
읽을 수 있는 일반 파일이 아닌 경로는 404로 답하는 대신 UncheckedIOException을 던진다.
프레임워크는 정리되어 사라진 내보내기 파일과 핸들러가 잘못 만든 경로를 구별할 수 없지만, 핸들러는 할 수 있다.
없는 파일에 404로 답하려는 핸들러는 file을 호출하기 전에 파일을 직접 확인한다:
if (!Files.isRegularFile(export)) {
throw new HttpException(HttpStatus.NOT_FOUND, "No export for that deck");
}
return WebResponse.file(export);
검사는 본문이 기록되는 동안이 아니라 응답이 만들어질 때 실행되므로, 실패는 여전히 핸들러 안에 있고 app.exception(…)이 답할 수 있다.
검증자와 조건부 요청은 여기가 아니라 정적 파일에 속한다. 핸들러가 선택한 파일은 정적 파일이 아니며, 그 파일이 바뀔 수 있는지는 핸들러만 안다.
헤더
header(name, value)는 헤더를 설정하고 header(name)은 이름의 대소문자 표기와 무관하게 헤더를 다시 읽는다.
HTTP에서 필드 이름은 대소문자를 구별하지 않으며, 헤더를 담는 맵도 같은 방식으로 비교한다.
withoutHeader(name)은 이름의 대소문자 표기와 무관하게 헤더를 제거하고, 나머지 헤더, 쿠키, 상태, 본문은 보존한다.
WebResponse response = WebResponse.text("ok").header("X-Trace-Id", trace);
response.header("x-trace-id"); // the trace id: the spelling asked for does not matter
response.headers(); // every field, in the order they were set
응답은 필드당 하나의 값을 가진다.
어떤 표기로든 필드를 다시 설정하면 값은 교체되고 이름과 위치는 그대로 남으므로, headers()는 실제로 전송되는 것을 정확히 다시 읽는다.
vary(field)는 교체하지 않고 추가하는 유일한 메서드인데, Vary는 목록을 담고 압축과 CORS가 각각 거기에 필드(Accept-Encoding과 Origin)를 더하기 때문이다.
한 번 이상 보내는 헤더는 범위 밖이며, headers()는 반환 타입으로 이를 드러낸다: Map<String, String>은 이름당 하나의 값을 약속한다.
쿠키는 자신만의 목록을 가진 예외로, cookie(…)로 추가하고 cookies()로 다시 읽는다.
한 답에 두 개의 Link 줄을 넣는 것처럼 반복되어야 하는 다른 헤더는 모두 raw를 통해 쓴다:
return WebResponse.raw((req, res) -> {
res.addHeader("Link", "<https://example.com/decks?page=2>; rel=\"next\"");
res.addHeader("Link", "<https://example.com/decks?page=9>; rel=\"last\"");
res.setStatus(HttpStatus.NO_CONTENT.code());
});
상태
empty(), empty(HttpStatus), noContent(), redirect(location), redirect(location, HttpStatus).
상태는 HttpStatus 상수이므로, 존재하지 않는 코드는 쓸 수 없다.
숫자가 런타임에만 도착할 때는 HttpStatus.of(int)로 찾는다.
리다이렉트
redirect(location)은 302 Found로 답하며, 위치를 Location 헤더에 담는다.
헤더가 담을 수 없는 문자, 즉 ASCII 밖의 문자, 공백, 제어 문자는 브라우저가 href를 인코딩하듯 UTF-8로 퍼센트 인코딩되고, 나머지는 주어진 그대로 나간다: redirect("/decks/한국어")는 /decks/%ED%95%9C%EA%B5%AD%EC%96%B4를 보낸다.
애플리케이션이 보통 원하는 것은 /decks/3 같은 경로이며, HTTP도 이를 허용한다.
WebResponse.redirect("/decks/" + deck.id()); // 302
WebResponse.redirect("/decks", HttpStatus.SEE_OTHER); // 303, after a POST
WebResponse.redirect("/new-home", HttpStatus.MOVED_PERMANENTLY); // 301, moved for good
WebResponse.redirect("/api/v2/decks", HttpStatus.PERMANENT_REDIRECT); // 308, keeps the method
302가 기본인 이유는 리다이렉트가 임시인 동안에만 되돌릴 수 있기 때문이다.
브라우저와 중간 장치는 301을 종종 무기한으로 캐시하므로, 잘못된 301은 코드가 고쳐진 뒤로도 한참 동안 방문자를 잘못된 곳으로 계속 보낸다.
302는 HttpServletResponse.sendRedirect가 보내는 상태이기도 하며, Javalin, Spark, Spring Web MVC, Express, Rails, Django가 모두 기본으로 쓰는 상태이다.
두 번째 인자는 다른 상태를 지정하며, 다른 모든 상태와 같은 이유로 int가 아닌 HttpStatus를 받는다.
3xx가 아닌 것은 거부한다.
200에 붙은 Location 헤더는 리다이렉트가 아니며, 어떤 클라이언트도 따라가지 않을 응답을 내보내는 것보다 호출 시점에 실패하는 편이 낫다.
넷 중 둘은 이름으로 알아 둘 만하다. 303은 POST 뒤의 리다이렉트를 뜻한다. 302가 실제로 그 일을 하고, 303은 그 뜻을 정확히 표현한다. 307과 308은 요청 메서드를 유지한다. POST 뒤에 301이나 302를 따라가는 브라우저는 보통 GET을 보내는데, 이 둘은 그 변경을 금지한다.
쿠키
return WebResponse.html(page)
.cookie("theme", "dark") // session cookie
.cookie("token", value, Duration.ofDays(7)) // survives a browser restart
.expireCookie("stale");
두 인자 형태는 Path=/, HttpOnly, SameSite=Lax를 설정하며, 이는 훔칠 가치가 있는 것을 담은 쿠키라면 갖춰야 할 속성이다.
cookie(Cookie)는 Secure, Domain, SameSite=None을 위해 직접 만든 jakarta.servlet.http.Cookie를 받는다.
응답은 속성까지 포함한 그 쿠키의 복사본을 보관한다.
expireCookie(name)은 기본값으로 설정된 쿠키를 지우라고 브라우저에 알린다.
이 메서드는 이 응답에서 쿠키를 빼는 것이 아니라, 그 이름에 Max-Age=0을 단 Set-Cookie를 더한다.
다른 경로나 도메인으로 설정된 쿠키는 같은 경로와 도메인에 최대 수명 0을 담은 cookie(Cookie)로 만료시킨다.
공백, 쉼표, 세미콜론, 백슬래시, 따옴표, ASCII 밖의 문자처럼 RFC 6265가 허용하지 않는 값은 쿠키를 설정하는 자리에서 IllegalArgumentException을 던지므로, 핸들러의 다른 실패처럼 exception(…)에 도달한다.
그런 문자를 담아야 하는 값은 예컨대 Base64.getUrlEncoder()로 먼저 인코딩한다.
클라이언트가 보낸 쿠키를 읽는 것은 요청에서 다룬다.
람다 계열
stream, sse, raw는 각각 자신만의 람다를 받으며, 그 람다 타입의 접미사가 속한 계열을 나타낸다.
…Handler는 WebResponse로 요청에 답한다: Handler와 ExceptionHandler이다.
…Writer는 아무것도 반환하지 않고 이미 정해진 응답의 본문을 채운다: StreamWriter(OutputStream), SseWriter(SseStream), ServletWriter(HttpServletRequest, HttpServletResponse)이다.
따라서 ServletWriter는 StreamWriter의 형제이지, Handler의 친척이 아니다.