Nesta aula, os textos soltos do Farol viram tipos: enums para métodos e códigos de estado, um record que desmonta o alvo, uma hierarquia selada para o conteúdo das respostas e um roteador com parâmetros no caminho, que leva cada requisição ao manipulador certo.
"A Primeira Pedra" deixou o Farol lendo o protocolo corretamente, mas falando uma língua frágil. O método era uma String, e nada impedia um manipulador de compará-lo com "get", em minúsculas, e nunca casar. A resposta guardava o código 404 e a frase "Not Found" em dois campos independentes, que podiam discordar. O alvo /tarefas/7?lista=casa chegava inteiro, e cada manipulador teria de separar o caminho da consulta e decodificar os %C3%A3. E um único manipulador atendia todas as requisições. Esta aula aplica ao Farol as Fases 2 e 3 do curso: enums de "Dados sem Cerimônia", records, hierarquias seladas e pattern matching de "Hierarquias Fechadas", e o resultado é um servidor em que boa parte dos erros possíveis é impedida pelo compilador.
Enums para o vocabulário fechado
Os métodos HTTP que o Farol entende formam um conjunto fechado, o caso de uso clássico de um enum:
package br.com.exemplo.farol.http;
import java.util.Optional;
/** Os métodos HTTP que o Farol entende. */
public enum Metodo {
GET, HEAD, POST, PUT, DELETE, OPTIONS;
/** O método com este nome exato (métodos HTTP diferenciam maiúsculas), se o Farol o conhecer. */
public static Optional<Metodo> de(String nome) {
for (Metodo m : values())
if (m.name().equals(nome)) return Optional.of(m);
return Optional.empty();
}
}
O método de não usa Metodo.valueOf, que lançaria IllegalArgumentException para um nome desconhecido, porque um método desconhecido vindo da rede não é um defeito do programa: é uma resposta a dar. A RFC 9110 define a resposta certa, 501 Not Implemented, "o servidor não reconhece o método". Métodos HTTP diferenciam maiúsculas de minúsculas, e get não é GET.
Os códigos de estado também formam um conjunto, e o enum resolve o problema do código e da frase que podiam discordar: cada constante carrega os dois, e é impossível criar um 404 com a frase errada:
package br.com.exemplo.farol.http;
/** Os códigos de estado que o Farol usa, com as frases da RFC 9110. */
public enum Status {
OK(200, "OK"),
CRIADO(201, "Created"),
SEM_CONTEUDO(204, "No Content"),
MOVIDO_PERMANENTEMENTE(301, "Moved Permanently"),
NAO_MODIFICADO(304, "Not Modified"),
REQUISICAO_INVALIDA(400, "Bad Request"),
PROIBIDO(403, "Forbidden"),
NAO_ENCONTRADO(404, "Not Found"),
METODO_NAO_PERMITIDO(405, "Method Not Allowed"),
TEMPO_ESGOTADO(408, "Request Timeout"),
TAMANHO_OBRIGATORIO(411, "Length Required"),
CORPO_GRANDE_DEMAIS(413, "Content Too Large"),
ALVO_LONGO_DEMAIS(414, "URI Too Long"),
CABECALHOS_GRANDES_DEMAIS(431, "Request Header Fields Too Large"),
ERRO_INTERNO(500, "Internal Server Error"),
NAO_IMPLEMENTADO(501, "Not Implemented"),
INDISPONIVEL(503, "Service Unavailable"),
VERSAO_NAO_SUPORTADA(505, "HTTP Version Not Supported");
private final int codigo;
private final String razao;
Status(int codigo, String razao) {
this.codigo = codigo;
this.razao = razao;
}
public int codigo() { return codigo; }
public String razao() { return razao; }
/** Respostas que, pela RFC 9110, nunca têm corpo nem Content-Length. */
public boolean proibeCorpo() {
return codigo == 204 || codigo == 304 || codigo < 200;
}
@Override public String toString() { return codigo + " " + razao; }
}
Os nomes das constantes estão em português, como todo o código do curso, e as frases seguem a RFC, porque são parte do protocolo. O método proibeCorpo concentra uma regra que, espalhada pelo código, seria esquecida: respostas 204 e 304 nunca têm corpo, e a 204 não pode nem ter Content-Length.
A ErroDeProtocolo passa a carregar um Status em vez de um número e uma frase, e ganha uma fábrica para o 501:
public final class ErroDeProtocolo extends Exception {
private static final long serialVersionUID = 1L;
private final Status status;
public ErroDeProtocolo(Status status, String detalhe) {
super(detalhe);
this.status = status;
}
public Status status() { return status; }
static ErroDeProtocolo requisicaoInvalida(String detalhe) { return new ErroDeProtocolo(Status.REQUISICAO_INVALIDA, detalhe); }
static ErroDeProtocolo alvoLongoDemais(String detalhe) { return new ErroDeProtocolo(Status.ALVO_LONGO_DEMAIS, detalhe); }
static ErroDeProtocolo cabecalhosGrandesDemais(String detalhe) { return new ErroDeProtocolo(Status.CABECALHOS_GRANDES_DEMAIS, detalhe); }
static ErroDeProtocolo corpoGrandeDemais(String detalhe) { return new ErroDeProtocolo(Status.CORPO_GRANDE_DEMAIS, detalhe); }
static ErroDeProtocolo tamanhoObrigatorio(String detalhe) { return new ErroDeProtocolo(Status.TAMANHO_OBRIGATORIO, detalhe); }
static ErroDeProtocolo naoImplementado(String detalhe) { return new ErroDeProtocolo(Status.NAO_IMPLEMENTADO, detalhe); }
static ErroDeProtocolo versaoNaoSuportada(String detalhe) { return new ErroDeProtocolo(Status.VERSAO_NAO_SUPORTADA, detalhe); }
}
O alvo desmontado
O alvo de uma requisição tem estrutura, definida pela RFC 3986, a especificação das URIs: um caminho, dividido em segmentos por /, e uma consulta opcional, depois do ?, com pares nome=valor separados por &. Caracteres especiais aparecem codificados: um % seguido de dois dígitos hexadecimais representa um byte, e o texto "João" num caminho vira Jo%C3%A3o, com os dois bytes UTF-8 do "ã". O record Alvo faz esse trabalho uma vez, na camada de protocolo:
package br.com.exemplo.farol.http;
import java.io.ByteArrayOutputStream;
import java.net.URLDecoder;
import java.nio.charset.*;
import java.util.*;
/**
* O alvo de uma requisição, já dividido: o caminho em segmentos decodificados
* e a consulta (depois do '?') em parâmetros.
*/
public record Alvo(String original, List<String> segmentos, Map<String, List<String>> consulta) {
public Alvo {
Objects.requireNonNull(original, "original");
segmentos = List.copyOf(segmentos);
Map<String, List<String>> copia = new LinkedHashMap<>();
consulta.forEach((k, v) -> copia.put(k, List.copyOf(v)));
consulta = Collections.unmodifiableMap(copia);
}
/** Interpreta um alvo como "/tarefas/7?lista=casa&ordem=prazo". */
public static Alvo de(String alvo) {
if (!alvo.startsWith("/")) throw new IllegalArgumentException("o alvo deve começar com '/': " + alvo);
int interrogacao = alvo.indexOf('?');
String caminho = interrogacao < 0 ? alvo : alvo.substring(0, interrogacao);
String textoDaConsulta = interrogacao < 0 ? "" : alvo.substring(interrogacao + 1);
List<String> segmentos = new ArrayList<>();
for (String s : caminho.substring(1).split("/", -1))
if (!s.isEmpty()) segmentos.add(decodificarSegmento(s)); // "/a//b/" vira [a, b]
Map<String, List<String>> consulta = new LinkedHashMap<>();
if (!textoDaConsulta.isEmpty()) {
for (String par : textoDaConsulta.split("&")) {
if (par.isEmpty()) continue;
int igual = par.indexOf('=');
String nome = igual < 0 ? par : par.substring(0, igual);
String valor = igual < 0 ? "" : par.substring(igual + 1);
consulta.computeIfAbsent(URLDecoder.decode(nome, StandardCharsets.UTF_8), k -> new ArrayList<>())
.add(URLDecoder.decode(valor, StandardCharsets.UTF_8));
}
}
return new Alvo(alvo, segmentos, consulta);
}
/** O caminho reconstruído, sem a consulta: "/tarefas/7". */
public String caminho() {
return "/" + String.join("/", segmentos);
}
public Optional<String> parametro(String nome) {
List<String> valores = consulta.getOrDefault(nome, List.of());
return valores.isEmpty() ? Optional.empty() : Optional.of(valores.getFirst());
}
/**
* Decodifica %XX num segmento de caminho. Ao contrário do URLDecoder,
* não transforma '+' em espaço, porque no caminho o '+' é literal.
*/
private static String decodificarSegmento(String segmento) {
if (segmento.indexOf('%') < 0) return segmento;
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
for (int i = 0; i < segmento.length(); i++) {
char c = segmento.charAt(i);
if (c == '%') {
if (i + 2 >= segmento.length()) throw new IllegalArgumentException("escape '%' incompleto em: " + segmento);
int alto = Character.digit(segmento.charAt(i + 1), 16), baixo = Character.digit(segmento.charAt(i + 2), 16);
if (alto < 0 || baixo < 0) throw new IllegalArgumentException("escape '%' inválido em: " + segmento);
bytes.write(alto * 16 + baixo);
i += 2;
} else {
bytes.write(c); // o alvo chegou como ISO-8859-1: cada char é um byte
}
}
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.decode(java.nio.ByteBuffer.wrap(bytes.toByteArray())).toString();
} catch (CharacterCodingException e) {
throw new IllegalArgumentException("UTF-8 inválido no caminho: " + segmento);
}
}
}
Há uma sutileza que pega muita gente. O URLDecoder do Java decodifica o formato application/x-www-form-urlencoded, dos formulários HTML, em que + significa espaço. Esse formato vale para a consulta, mas não para o caminho, onde + é só um +. Por isso o caminho tem seu próprio decodificador, que também é mais rigoroso: um escape incompleto (%2) ou bytes que não formam UTF-8 válido (%C3 sozinho) são recusados, em vez de viraram o caractere de substituição � em silêncio. O decodificador do java.nio.charset, configurado com CodingErrorAction.REPORT, é a ferramenta para isso. Segmentos vazios são descartados, então /tarefas/ e /tarefas são o mesmo caminho.
A requisição e a resposta tipadas
Com os tipos novos, a Requisicao fica assim:
package br.com.exemplo.farol.http;
import java.util.*;
/**
* Uma requisição HTTP já lida e validada. Os parâmetros do caminho
* (como o {id} de "/tarefas/{id}") são preenchidos pelo roteador.
*/
public record Requisicao(Metodo metodo, Alvo alvo, String versao, Cabecalhos cabecalhos, Corpo corpo,
Map<String, String> parametrosDoCaminho) {
public Requisicao {
Objects.requireNonNull(metodo, "metodo");
Objects.requireNonNull(alvo, "alvo");
Objects.requireNonNull(versao, "versao");
Objects.requireNonNull(cabecalhos, "cabecalhos");
Objects.requireNonNull(corpo, "corpo");
parametrosDoCaminho = Map.copyOf(parametrosDoCaminho);
}
public Requisicao(Metodo metodo, Alvo alvo, String versao, Cabecalhos cabecalhos, Corpo corpo) {
this(metodo, alvo, versao, cabecalhos, corpo, Map.of());
}
/** Uma cópia com os parâmetros do caminho preenchidos. */
public Requisicao comParametros(Map<String, String> parametros) {
return new Requisicao(metodo, alvo, versao, cabecalhos, corpo, parametros);
}
/** Um parâmetro do caminho declarado na rota; a ausência é um defeito da rota, não do cliente. */
public String parametro(String nome) {
String valor = parametrosDoCaminho.get(nome);
if (valor == null) throw new IllegalStateException("a rota não declara o parâmetro {" + nome + "}");
return valor;
}
/** Um parâmetro da consulta (depois do '?'), que o cliente pode ou não ter enviado. */
public Optional<String> consulta(String nome) {
return alvo.parametro(nome);
}
public String corpoComoTexto() {
return corpo.comoTexto();
}
}
Os dois tipos de parâmetro têm contratos diferentes, e a assinatura deixa isso visível, no espírito de "Contratos à Vista". Um parâmetro de caminho, como o {id}, sempre existe se a rota o declarou; se um manipulador pedir um que não existe, é um defeito do programador, e a exceção não verificada IllegalStateException vira um 500. Um parâmetro de consulta pode faltar a qualquer momento, porque depende do cliente, e por isso devolve Optional. O construtor secundário, sem os parâmetros, é o que o leitor usa; o roteador preenche os parâmetros depois, com comParametros, que cria uma cópia, como os métodos wither da aula anterior.
No leitor, a linha de pedido passa a produzir esses tipos. O trecho alterado:
String nomeDoMetodo = partes[0], textoDoAlvo = partes[1], versao = partes[2];
if (!TOKEN.matcher(nomeDoMetodo).matches())
throw ErroDeProtocolo.requisicaoInvalida("método inválido: '" + nomeDoMetodo + "'");
validarVersao(versao);
Metodo metodo = Metodo.de(nomeDoMetodo)
.orElseThrow(() -> ErroDeProtocolo.naoImplementado("método desconhecido: " + nomeDoMetodo));
Alvo alvo;
try {
alvo = Alvo.de(textoDoAlvo);
} catch (IllegalArgumentException e) {
throw ErroDeProtocolo.requisicaoInvalida(e.getMessage());
}
A ordem das verificações segue a da RFC: um método sintaticamente inválido é 400, uma versão não suportada é 505, e um método bem formado mas desconhecido é 501. O IllegalArgumentException do Alvo, que para o record é um erro de uso, é traduzido na fronteira em 400, um erro do cliente.
O conteúdo como hierarquia selada
A resposta precisa de um corpo, e aqui está o ponto onde os tipos selados de "Hierarquias Fechadas" brilham. Uma resposta pode não ter corpo nenhum, como um 204, ou ter bytes em memória com um tipo de mídia, como um JSON. Em "A Pedra de Acabamento", ela vai poder ter um terceiro tipo de conteúdo, um arquivo em disco, enviado sem ser carregado na memória. Três formas diferentes, cada uma com seus dados, e um código que precisa tratar cada uma de um jeito: é uma hierarquia fechada.
package br.com.exemplo.farol.http;
import java.util.Objects;
/** O que vai no corpo de uma resposta. Hierarquia fechada: o escritor trata cada caso. */
public sealed interface Conteudo {
/** Nenhum corpo. */
record Vazio() implements Conteudo {}
/** Bytes em memória, com o seu tipo de mídia. */
record Bytes(Corpo corpo, String tipo) implements Conteudo {
public Bytes {
Objects.requireNonNull(corpo, "corpo");
Objects.requireNonNull(tipo, "tipo");
}
}
Conteudo VAZIO = new Vazio();
}
Como os dois records estão declarados dentro da interface, no mesmo arquivo, o compilador deduz a lista de subtipos permitidos, e a cláusula permits pode ser omitida. O tipo de mídia, como application/json, fica junto dos bytes, porque é uma propriedade deles: não faz sentido um Content-Type sem corpo, nem um corpo sem tipo.
A Resposta agora usa Status e Conteudo, e ganha fábricas com nome para os casos mais comuns:
package br.com.exemplo.farol.http;
import java.util.Objects;
/** Uma resposta HTTP, com fábricas para os casos mais comuns. */
public record Resposta(Status status, Cabecalhos cabecalhos, Conteudo conteudo) {
public Resposta {
Objects.requireNonNull(status, "status");
Objects.requireNonNull(cabecalhos, "cabecalhos");
Objects.requireNonNull(conteudo, "conteudo");
if (status.proibeCorpo() && !(conteudo instanceof Conteudo.Vazio))
throw new IllegalArgumentException("a resposta " + status + " não pode ter corpo");
}
public static Resposta de(Status status, String tipo, String texto) {
return new Resposta(status, Cabecalhos.VAZIOS, new Conteudo.Bytes(Corpo.deTexto(texto), tipo));
}
public static Resposta texto(Status status, String texto) {
return de(status, "text/plain; charset=utf-8", texto);
}
public static Resposta texto(String texto) {
return texto(Status.OK, texto);
}
public static Resposta html(String html) {
return de(Status.OK, "text/html; charset=utf-8", html);
}
public static Resposta json(Status status, String json) {
return de(status, "application/json", json);
}
public static Resposta json(String json) {
return json(Status.OK, json);
}
public static Resposta vazia(Status status) {
return new Resposta(status, Cabecalhos.VAZIOS, Conteudo.VAZIO);
}
/** Uma resposta de erro padronizada, em texto simples. */
public static Resposta erro(Status status, String mensagem) {
return texto(status, status + ": " + mensagem + "\n");
}
public static Resposta criado(String local, String json) {
return json(Status.CRIADO, json).comCabecalho("Location", local);
}
public static Resposta redirecionar(String local) {
return vazia(Status.MOVIDO_PERMANENTEMENTE).comCabecalho("Location", local);
}
public Resposta comCabecalho(String nome, String valor) {
return new Resposta(status, cabecalhos.comUnico(nome, valor), conteudo);
}
}
O construtor compacto recusa uma combinação que o protocolo proíbe, um 204 com corpo, no momento em que alguém tenta criá-la. O defeito aparece no manipulador que o cometeu, e não como um cliente confuso com bytes inesperados. As fábricas, pelo item 1 de Effective Java, dão nome à intenção: Resposta.criado("/tarefas/3", json) é um 201 com Location e JSON, numa linha.
O escritor com switch exaustivo
O escritor de respostas passa a decidir o Content-Type e o Content-Length pelo tipo do conteúdo, com um switch sobre a hierarquia selada e padrões de record:
private static final Set<String> CONTROLADOS = Set.of("content-length", "content-type", "connection", "date", "server");
public void escrever(Resposta resposta, OutputStream saida, boolean semCorpo, boolean fechar) throws IOException {
Status status = resposta.status();
StringBuilder cabeca = new StringBuilder(256);
cabeca.append("HTTP/1.1 ").append(status.codigo()).append(' ');
linha(cabeca, status.razao());
Cabecalhos cabecalhos = resposta.cabecalhos();
for (String nome : cabecalhos.nomes()) {
if (CONTROLADOS.contains(nome.toLowerCase(Locale.ROOT))) continue; // o escritor decide estes
for (String valor : cabecalhos.todos(nome)) {
cabeca.append(nome).append(": ");
linha(cabeca, valor);
}
}
// O tipo e o tamanho saem do conteúdo; o compilador garante que todos os casos foram tratados
long tamanho = switch (resposta.conteudo()) {
case Conteudo.Vazio() -> 0;
case Conteudo.Bytes(Corpo corpo, String tipo) -> {
cabeca.append("Content-Type: ");
linha(cabeca, tipo);
yield corpo.tamanho();
}
};
if (!status.proibeCorpo()) cabeca.append("Content-Length: ").append(tamanho).append("\r\n");
cabeca.append("Date: ").append(DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(relogio))).append("\r\n");
cabeca.append("Server: Farol\r\n");
if (fechar) cabeca.append("Connection: close\r\n");
cabeca.append("\r\n");
saida.write(cabeca.toString().getBytes(StandardCharsets.ISO_8859_1));
if (!semCorpo) {
switch (resposta.conteudo()) {
case Conteudo.Vazio() -> { }
case Conteudo.Bytes(Corpo corpo, var _) -> corpo.escreverEm(saida);
}
}
saida.flush();
}
Os dois switch não têm default, e não precisam: como Conteudo é selado, o compilador sabe que Vazio e Bytes são os únicos casos e verifica que ambos foram tratados. No segundo, o tipo de mídia não interessa, e a variável sem nome _, finalizada no Java 22, diz isso explicitamente, em vez de declarar uma variável que nunca seria usada.
O valor dessa verificação aparece quando a hierarquia cresce. Basta acrescentar um terceiro caso à interface, como o arquivo que "A Pedra de Acabamento" vai precisar:
record Arquivo(java.nio.file.Path caminho, String tipo) implements Conteudo {}
e a compilação do projeto falha:
src/main/java/br/com/exemplo/farol/protocolo/EscritorDeResposta.java:48: error: the switch expression does not cover all possible input values
long tamanho = switch (resposta.conteudo()) {
^
src/main/java/br/com/exemplo/farol/protocolo/EscritorDeResposta.java:64: error: the switch statement does not cover all possible input values
switch (resposta.conteudo()) {
^
2 errors
O compilador aponta exatamente os dois lugares do programa que precisam aprender a tratar o caso novo. Com uma hierarquia aberta e instanceof encadeados, o arquivo cairia silenciosamente num else genérico, e o erro só apareceria quando um cliente recebesse uma resposta errada.
O roteador
O roteador decide qual manipulador atende cada requisição, a partir do método e do caminho. As rotas são declaradas com padrões como /tarefas/{id}, em que cada segmento é fixo ou um parâmetro. É uma segunda hierarquia selada, pequena e interna ao pacote rotas:
package br.com.exemplo.farol.rotas;
import java.util.*;
/** Um padrão de caminho como "/tarefas/{id}", dividido em segmentos fixos e parâmetros. */
record Padrao(String texto, List<Padrao.Segmento> segmentos) {
sealed interface Segmento {}
record Literal(String valor) implements Segmento {}
record Parametro(String nome) implements Segmento {}
Padrao {
segmentos = List.copyOf(segmentos);
}
static Padrao de(String texto) {
if (!texto.startsWith("/")) throw new IllegalArgumentException("o padrão deve começar com '/': " + texto);
List<Segmento> segmentos = new ArrayList<>();
Set<String> nomes = new HashSet<>();
for (String s : texto.substring(1).split("/")) {
if (s.isEmpty()) continue;
if (s.startsWith("{") && s.endsWith("}")) {
String nome = s.substring(1, s.length() - 1);
if (nome.isBlank()) throw new IllegalArgumentException("parâmetro sem nome em: " + texto);
if (!nomes.add(nome)) throw new IllegalArgumentException("parâmetro {" + nome + "} repetido em: " + texto);
segmentos.add(new Parametro(nome));
} else if (s.contains("{") || s.contains("}")) {
throw new IllegalArgumentException("chaves só podem envolver um segmento inteiro: " + texto);
} else {
segmentos.add(new Literal(s));
}
}
return new Padrao(texto, segmentos);
}
/** Os parâmetros extraídos, se o caminho casar com o padrão. */
Optional<Map<String, String>> casar(List<String> caminho) {
if (caminho.size() != segmentos.size()) return Optional.empty();
Map<String, String> parametros = new LinkedHashMap<>();
for (int i = 0; i < segmentos.size(); i++) {
String parte = caminho.get(i);
switch (segmentos.get(i)) {
case Literal(String valor) -> {
if (!valor.equals(parte)) return Optional.empty();
}
case Parametro(String nome) -> parametros.put(nome, parte);
}
}
return Optional.of(parametros);
}
/** Quantos segmentos fixos o padrão tem: quanto mais, mais específico. */
long literais() {
return segmentos.stream().filter(s -> s instanceof Literal).count();
}
/** A "forma" do padrão, ignorando os nomes dos parâmetros: "/tarefas/{id}" e "/tarefas/{x}" têm a mesma. */
String forma() {
StringBuilder sb = new StringBuilder();
for (Segmento s : segmentos)
sb.append('/').append(switch (s) {
case Literal(String valor) -> valor;
case Parametro(var _) -> "{}";
});
return sb.isEmpty() ? "/" : sb.toString();
}
}
O Padrao.de valida o padrão no momento em que a rota é registrada, na inicialização do programa: um {} sem nome, um parâmetro repetido ou chaves no meio de um segmento, como /arquivo{id}, são erros de quem escreveu a rota, e ninguém deve descobri-los com o servidor já no ar.
O roteador reúne as rotas e faz a busca. O resultado da busca é, de novo, um de exatamente três casos, e a hierarquia selada os torna explícitos:
package br.com.exemplo.farol.rotas;
import br.com.exemplo.farol.http.*;
import java.util.*;
import java.util.stream.Collectors;
/**
* Decide qual manipulador atende cada requisição, pelo método e pelo caminho.
* O próprio roteador é um Manipulador, então o servidor não precisa conhecê-lo.
*/
public final class Roteador implements Manipulador {
private record Rota(Metodo metodo, Padrao padrao, Manipulador manipulador) {}
/** O resultado de uma busca: exatamente um destes três casos. */
sealed interface Busca {}
record Encontrada(Manipulador manipulador, Map<String, String> parametros) implements Busca {}
record MetodoNaoPermitido(Set<Metodo> permitidos) implements Busca {}
record NaoEncontrada() implements Busca {}
private final List<Rota> rotas = new ArrayList<>();
public Roteador() {}
public Roteador get(String padrao, Manipulador m) { return adicionar(Metodo.GET, padrao, m); }
public Roteador post(String padrao, Manipulador m) { return adicionar(Metodo.POST, padrao, m); }
public Roteador put(String padrao, Manipulador m) { return adicionar(Metodo.PUT, padrao, m); }
public Roteador delete(String padrao, Manipulador m) { return adicionar(Metodo.DELETE, padrao, m); }
public Roteador adicionar(Metodo metodo, String textoDoPadrao, Manipulador manipulador) {
Objects.requireNonNull(manipulador, "manipulador");
Padrao padrao = Padrao.de(textoDoPadrao);
for (Rota r : rotas)
if (r.metodo() == metodo && r.padrao().forma().equals(padrao.forma()))
throw new IllegalArgumentException("rota duplicada: " + metodo + " " + textoDoPadrao
+ " (conflita com " + r.padrao().texto() + ")");
rotas.add(new Rota(metodo, padrao, manipulador));
return this;
}
Busca buscar(Metodo metodo, Alvo alvo) {
// 1. Todas as rotas cujo caminho casa, com os parâmetros extraídos
record Candidata(Rota rota, Map<String, String> parametros) {}
List<Candidata> candidatas = new ArrayList<>();
for (Rota r : rotas)
r.padrao().casar(alvo.segmentos()).ifPresent(p -> candidatas.add(new Candidata(r, p)));
if (candidatas.isEmpty()) return new NaoEncontrada();
// 2. Dessas, as que aceitam o método (HEAD usa as rotas de GET), a mais específica primeiro
Metodo procurado = metodo == Metodo.HEAD ? Metodo.GET : metodo;
Optional<Candidata> escolhida = candidatas.stream()
.filter(c -> c.rota().metodo() == procurado)
.max(Comparator.comparingLong(c -> c.rota().padrao().literais()));
if (escolhida.isPresent())
return new Encontrada(escolhida.get().rota().manipulador(), escolhida.get().parametros());
// 3. O caminho existe, mas não com este método
Set<Metodo> permitidos = EnumSet.of(Metodo.OPTIONS);
for (Candidata c : candidatas) {
permitidos.add(c.rota().metodo());
if (c.rota().metodo() == Metodo.GET) permitidos.add(Metodo.HEAD);
}
return new MetodoNaoPermitido(permitidos);
}
@Override
public Resposta atender(Requisicao requisicao) throws Exception {
return switch (buscar(requisicao.metodo(), requisicao.alvo())) {
case Encontrada(Manipulador m, Map<String, String> parametros) ->
m.atender(requisicao.comParametros(parametros));
case MetodoNaoPermitido(Set<Metodo> permitidos) when requisicao.metodo() == Metodo.OPTIONS ->
Resposta.vazia(Status.SEM_CONTEUDO).comCabecalho("Allow", juntar(permitidos));
case MetodoNaoPermitido(Set<Metodo> permitidos) ->
Resposta.erro(Status.METODO_NAO_PERMITIDO, requisicao.metodo() + " não é aceito em " + requisicao.alvo().caminho())
.comCabecalho("Allow", juntar(permitidos));
case NaoEncontrada() ->
Resposta.erro(Status.NAO_ENCONTRADO, requisicao.alvo().caminho());
};
}
private static String juntar(Set<Metodo> metodos) {
return metodos.stream().map(Metodo::name).collect(Collectors.joining(", "));
}
}
A busca tem três passos, e cada um corresponde a uma regra do protocolo. Primeiro, reúne todas as rotas cujo caminho casa, ignorando o método; o record local Candidata, declarado dentro do próprio método, guarda cada rota com seus parâmetros extraídos. Se nenhuma casa, o recurso não existe: 404. Segundo, entre as candidatas, procura as que aceitam o método, com uma regra da RFC 9110: HEAD usa a rota de GET, e o escritor omite o corpo. Se várias casam, vence a mais específica, a que tem mais segmentos fixos, e é por isso que /tarefas/novas vence /tarefas/{id} quando o caminho é exatamente /tarefas/novas. Terceiro, se o caminho existe mas o método não, a resposta é 405, e a RFC exige o cabeçalho Allow com os métodos aceitos. O EnumSet de "Listas, Conjuntos e Mapas" os mantém na ordem de declaração do enum.
O atender é um switch sobre a Busca com padrões de record e uma guarda when, de "Hierarquias Fechadas": um OPTIONS num caminho existente, que é justamente a pergunta "quais métodos este recurso aceita?", recebe 204 com o Allow, enquanto qualquer outro método não permitido recebe 405. A ordem dos casos importa: o caso com guarda precisa vir antes do caso geral do mesmo tipo.
E o roteador implementa Manipulador. O servidor continua recebendo um único manipulador e não sabe que, por trás dele, há uma tabela de rotas. Poderia haver um roteador dentro de outro, ou um manipulador que envolve o roteador para medir o tempo de cada requisição, como pede o exercício 5. É a composição de "Lições de Quem Veio Antes": objetos com a mesma interface, que se encaixam uns nos outros.
No servidor, as mudanças são pequenas: as respostas de erro usam Resposta.erro(e.status(), e.getMessage()), o 408 usa Status.TEMPO_ESGOTADO, o 500 usa Status.ERRO_INTERNO, e o teste do HEAD compara enums, requisicao.metodo() == Metodo.HEAD, em vez de textos.
Uma API de verdade
Com o roteador, o Farol pode servir uma pequena API de tarefas, guardadas em memória. Primeiro, o mínimo de JSON, escrito à mão, porque o Farol não usa bibliotecas; a única parte delicada é escapar as aspas, barras e caracteres de controle dos textos:
package br.com.exemplo.farol.app;
/** O mínimo de JSON de que a aplicação de exemplo precisa, sem bibliotecas. */
final class Json {
private Json() {}
/** Um texto JSON entre aspas, com os caracteres especiais escapados. */
static String texto(String s) {
StringBuilder sb = new StringBuilder(s.length() + 2).append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> {
if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
else sb.append(c);
}
}
}
return sb.append('"').toString();
}
}
A API registra cinco rotas no roteador que recebe:
package br.com.exemplo.farol.app;
import br.com.exemplo.farol.http.*;
import br.com.exemplo.farol.rotas.Roteador;
import java.util.*;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/** Uma pequena API de tarefas, guardadas em memória. */
final class ApiDeTarefas {
record Tarefa(int id, String titulo, boolean concluida) {
String json() {
return "{\"id\":" + id + ",\"titulo\":" + Json.texto(titulo) + ",\"concluida\":" + concluida + "}";
}
}
// Seguros entre threads desde já: a terceira pedra vai atender várias conexões ao mesmo tempo
private final Map<Integer, Tarefa> tarefas = new ConcurrentSkipListMap<>();
private final AtomicInteger proximoId = new AtomicInteger(1);
void registrarEm(Roteador rotas) {
rotas.get("/tarefas", this::listar)
.post("/tarefas", this::criar)
.get("/tarefas/{id}", this::buscar)
.put("/tarefas/{id}/concluida", this::concluir)
.delete("/tarefas/{id}", this::remover);
}
private Resposta listar(Requisicao req) {
boolean soPendentes = req.consulta("pendentes").map(Boolean::parseBoolean).orElse(false);
String itens = tarefas.values().stream()
.filter(t -> !soPendentes || !t.concluida())
.map(Tarefa::json)
.collect(Collectors.joining(",", "[", "]"));
return Resposta.json(itens);
}
private Resposta criar(Requisicao req) {
String titulo = req.corpoComoTexto().strip();
if (titulo.isEmpty() || titulo.length() > 200)
return Resposta.erro(Status.REQUISICAO_INVALIDA, "o título deve ter de 1 a 200 caracteres");
Tarefa nova = new Tarefa(proximoId.getAndIncrement(), titulo, false);
tarefas.put(nova.id(), nova);
return Resposta.criado("/tarefas/" + nova.id(), nova.json());
}
private Resposta buscar(Requisicao req) {
return comTarefa(req, t -> Resposta.json(t.json()));
}
private Resposta concluir(Requisicao req) {
return comTarefa(req, t -> {
Tarefa concluida = new Tarefa(t.id(), t.titulo(), true);
tarefas.put(t.id(), concluida);
return Resposta.json(concluida.json());
});
}
private Resposta remover(Requisicao req) {
return comTarefa(req, t -> {
tarefas.remove(t.id());
return Resposta.vazia(Status.SEM_CONTEUDO);
});
}
/** Converte o {id}, busca a tarefa e aplica a ação; cuida dos dois erros possíveis num lugar só. */
private Resposta comTarefa(Requisicao req, java.util.function.Function<Tarefa, Resposta> acao) {
int id;
try {
id = Integer.parseInt(req.parametro("id"));
} catch (NumberFormatException e) {
return Resposta.erro(Status.REQUISICAO_INVALIDA, "id deve ser um número: " + req.parametro("id"));
}
Tarefa t = tarefas.get(id);
return t == null ? Resposta.erro(Status.NAO_ENCONTRADO, "tarefa " + id) : acao.apply(t);
}
}
Os manipuladores são referências a métodos, this::listar, que se encaixam na interface Manipulador como qualquer lambda. O mapa é um ConcurrentSkipListMap e o contador é um AtomicInteger, das ferramentas de "Ferramentas de Precisão": por enquanto o Farol atende uma conexão por vez, mas a próxima aula muda isso, e uma estrutura de dados que não é segura entre threads seria uma armadilha esperando para disparar. O mapa ordenado mantém as tarefas em ordem de id na listagem.
O Principal monta as rotas:
package br.com.exemplo.farol.app;
import br.com.exemplo.farol.http.Resposta;
import br.com.exemplo.farol.nucleo.Servidor;
import br.com.exemplo.farol.protocolo.Limites;
import br.com.exemplo.farol.rotas.Roteador;
public class Principal {
public static void main(String[] args) throws Exception {
int porta = args.length > 0 ? Integer.parseInt(args[0]) : 8080;
Roteador rotas = new Roteador()
.get("/", req -> Resposta.html("<h1>Farol</h1><p>Bem-vindo à Ilha do Conhecimento.</p>"))
.get("/ola/{nome}", req -> Resposta.texto("Olá, " + req.parametro("nome") + "!\n"));
new ApiDeTarefas().registrarEm(rotas);
try (Servidor farol = new Servidor(porta, Limites.PADRAO, rotas)) {
farol.atenderParaSempre();
}
}
}
E o module-info.java passa a exportar também o pacote rotas. Uma sessão com o curl, com os cabeçalhos Date e Server omitidos para economizar espaço:
$ curl -i -X POST -d "Estudar sockets" http://localhost:8080/tarefas
HTTP/1.1 201 Created
Location: /tarefas/1
Content-Type: application/json
Content-Length: 53
Connection: close
{"id":1,"titulo":"Estudar sockets","concluida":false}
$ curl -X POST -d 'Revisar "Contratos à Vista"' http://localhost:8080/tarefas
{"id":2,"titulo":"Revisar \"Contratos à Vista\"","concluida":false}
$ curl -X PUT http://localhost:8080/tarefas/1/concluida
{"id":1,"titulo":"Estudar sockets","concluida":true}
$ curl "http://localhost:8080/tarefas?pendentes=true"
[{"id":2,"titulo":"Revisar \"Contratos à Vista\"","concluida":false}]
$ curl http://localhost:8080/tarefas/abc
400 Bad Request: id deve ser um número: abc
$ curl -i -X DELETE http://localhost:8080/tarefas/1
HTTP/1.1 204 No Content
Connection: close
$ curl -X DELETE http://localhost:8080/tarefas/1
404 Not Found: tarefa 1
$ curl -X PATCH http://localhost:8080/tarefas
501 Not Implemented: método desconhecido: PATCH
$ curl -i -X PUT http://localhost:8080/tarefas
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, POST, OPTIONS
...
405 Method Not Allowed: PUT não é aceito em /tarefas
$ curl -i -X OPTIONS http://localhost:8080/tarefas/2
HTTP/1.1 204 No Content
Allow: GET, HEAD, DELETE, OPTIONS
Connection: close
$ curl http://localhost:8080/ola/Jo%C3%A3o%20Pedro
Olá, João Pedro!
Cada linha exercita uma parte da aula: o 201 com Location, o JSON com aspas escapadas e acento intacto, a consulta ?pendentes=true, o 400 de um id que não é número, o 204 sem Content-Length, o 404 de uma tarefa já removida, o 501 de um método desconhecido, o 405 com Allow, o OPTIONS e o caminho decodificado de Jo%C3%A3o%20Pedro.
Os testes
O roteador é testado sem rede nenhuma, porque é só um Manipulador: o teste monta uma Requisicao e chama atender.
package br.com.exemplo.farol.rotas;
import br.com.exemplo.farol.http.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RoteadorTest {
private Roteador rotas;
@BeforeEach
void configurar() {
rotas = new Roteador()
.get("/tarefas", req -> Resposta.texto("lista"))
.post("/tarefas", req -> Resposta.texto("criou"))
.get("/tarefas/{id}", req -> Resposta.texto("tarefa " + req.parametro("id")))
.get("/tarefas/novas", req -> Resposta.texto("novas"))
.get("/usuarios/{u}/tarefas/{t}", req -> Resposta.texto(req.parametro("u") + ":" + req.parametro("t")));
}
private Resposta pedir(Metodo metodo, String alvo) throws Exception {
return rotas.atender(new Requisicao(metodo, Alvo.de(alvo), "HTTP/1.1", Cabecalhos.VAZIOS, Corpo.VAZIO));
}
private static String texto(Resposta r) {
return switch (r.conteudo()) {
case Conteudo.Bytes(Corpo corpo, var _) -> corpo.comoTexto();
case Conteudo.Vazio() -> "";
};
}
@Test
void extraiParametrosDoCaminho() throws Exception {
assertThat(texto(pedir(Metodo.GET, "/tarefas/42"))).isEqualTo("tarefa 42");
assertThat(texto(pedir(Metodo.GET, "/usuarios/ana/tarefas/7"))).isEqualTo("ana:7");
}
@Test
void segmentoFixoVenceParametro() throws Exception {
assertThat(texto(pedir(Metodo.GET, "/tarefas/novas"))).isEqualTo("novas");
}
@Test
void metodoErradoDa405ComAllow() throws Exception {
Resposta r = pedir(Metodo.DELETE, "/tarefas");
assertThat(r.status()).isEqualTo(Status.METODO_NAO_PERMITIDO);
assertThat(r.cabecalhos().valor("Allow")).hasValue("GET, HEAD, POST, OPTIONS");
}
@Test
void recusaRotaDuplicadaMesmoComOutroNomeDeParametro() {
assertThatThrownBy(() -> rotas.get("/tarefas/{codigo}", req -> Resposta.texto("x")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("rota duplicada");
}
@Test
void buscaDevolveOTipoCerto() {
Alvo alvo = Alvo.de("/tarefas");
assertThat(rotas.buscar(Metodo.GET, alvo) instanceof Roteador.Encontrada).isTrue();
assertThat(rotas.buscar(Metodo.PUT, alvo)).isEqualTo(new Roteador.MetodoNaoPermitido(
Set.of(Metodo.GET, Metodo.HEAD, Metodo.POST, Metodo.OPTIONS)));
assertThat(rotas.buscar(Metodo.GET, Alvo.de("/x"))).isEqualTo(new Roteador.NaoEncontrada());
}
}
O último teste mostra outra vantagem dos records: como MetodoNaoPermitido e NaoEncontrada têm equals gerado, o resultado da busca pode ser comparado diretamente com o valor esperado. Um EnumSet e um Set.of com os mesmos elementos são iguais, pelo contrato de Set.equals. O arquivo completo tem dez testes, incluindo o HEAD que usa a rota de GET, o OPTIONS, o 404 para caminhos com segmentos a mais e os padrões malformados.
O AlvoTest cobre a decodificação: /ola/Jo%C3%A3o+Pedro produz o segmento João+Pedro, com o + preservado, enquanto ?q=java+moderno produz java moderno; barras repetidas são ignoradas; um parâmetro repetido guarda todos os valores; e %2 e %C3 sozinhos são recusados. O EscritorDeRespostaTest, com um relógio fixo, compara a resposta inteira, byte a byte, verifica que o HEAD mantém o Content-Length sem o corpo, que o 204 não tem Content-Length e que a injeção de cabeçalho é recusada:
@Test
void recusaInjecaoDeCabecalho() {
Resposta maliciosa = Resposta.redirecionar("/inicio\r\nSet-Cookie: sessao=roubada");
assertThatThrownBy(() -> escrever(maliciosa, false, false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("quebra de linha");
}
Com os testes da aula anterior atualizados para os tipos novos e mais dois para o 501 e para o escape inválido no caminho:
Tests run: 49, Failures: 0, Skipped: 0
O Farol fala agora uma língua de tipos: o compilador impede um 204 com corpo de passar despercebido, obriga o escritor a tratar todo tipo de conteúdo e garante que cada resultado de busca de rota tenha uma resposta. Mas continua atendendo uma conexão por vez, e fecha cada conexão depois de uma única resposta.
Fontes e leituras recomendadas
- JEP 409, "Sealed Classes", JEP 440, "Record Patterns" e JEP 441, "Pattern Matching for switch": as três propostas que tornaram possível o estilo desta aula, com a motivação e as regras de exaustividade. https://openjdk.org/jeps/409, https://openjdk.org/jeps/440 e https://openjdk.org/jeps/441
- JEP 456, "Unnamed Variables & Patterns": a variável sem nome
_, finalizada no Java 22. https://openjdk.org/jeps/456 - RFC 3986, "Uniform Resource Identifier (URI): Generic Syntax", seções 2 e 3: a codificação por porcentagem, o caminho e a consulta. https://www.rfc-editor.org/rfc/rfc3986
- RFC 9110, "HTTP Semantics", seção 9 (métodos) e seção 10.2.1 (cabeçalho
Allow): as regras paraHEAD,OPTIONSe o 405. https://www.rfc-editor.org/rfc/rfc9110 - Brian Goetz, "Data Oriented Programming in Java" (InfoQ, 2022): o artigo do arquiteto da linguagem que explica como records, tipos selados e pattern matching se combinam para modelar dados, exatamente o estilo do
Conteudoe daBusca. https://www.infoq.com/articles/data-oriented-programming-java/
Exercícios
Exercício 1
O Status tem o método proibeCorpo(), e o construtor da Resposta o usa. Explique por que essa regra ficou no enum e no record, e não no escritor, e o que aconteceria se ela só existisse no escritor.
Ver resposta
✓ Resposta: A regra "204 e 304 não têm corpo" é uma propriedade do código de estado, e o lugar natural para uma propriedade de uma constante é a própria constante, como os atributos de enum de "Dados sem Cerimônia". O construtor da Resposta a usa para impedir que um objeto inválido exista: um manipulador que tente criar um 204 com corpo recebe uma IllegalArgumentException na linha que cometeu o erro, e o Farol transforma isso num 500 com a pilha no registro, apontando o culpado. Se a regra existisse só no escritor, haveria duas opções, ambas piores. O escritor poderia descartar o corpo em silêncio, e o manipulador ficaria com um defeito escondido, que um teste do manipulador nunca revelaria, porque o teste examina a Resposta, não os bytes. Ou o escritor poderia lançar a exceção no momento de escrever, longe de quem criou a resposta, com a pilha apontando para o escritor em vez do manipulador. É o princípio de "Contratos à Vista": validar na criação, para que todo objeto que existe seja válido. O escritor ainda usa proibeCorpo() para omitir o Content-Length, e isso não é duplicação, porque é outra regra, sobre os cabeçalhos, derivada da mesma propriedade.
Exercício 2
Com as rotas GET /tarefas/{id} e GET /{recurso}/novas, qual manipulador atende GET /tarefas/novas? Explique pelo código do roteador e proponha uma forma de detectar esse tipo de ambiguidade.
Ver resposta
✓ Resposta: Os dois padrões casam com /tarefas/novas, e os dois têm exatamente um segmento fixo, então a especificidade empata. O Stream.max com um comparador, em caso de empate, mantém o primeiro elemento encontrado, e as candidatas estão na ordem de registro. Num teste, a resposta foi "tarefa novas": venceu /tarefas/{id}, registrada primeiro, com id igual a "novas". Esse comportamento é previsível, mas frágil: reordenar as linhas de registro muda qual manipulador atende a requisição, sem nenhum aviso. Uma forma de detectar a ambiguidade é verificar, em adicionar, se o padrão novo e algum padrão existente do mesmo método podem casar com o mesmo caminho e têm a mesma quantidade de literais. Dois padrões do mesmo tamanho podem casar com o mesmo caminho se, em cada posição, pelo menos um dos dois é parâmetro ou os dois literais são iguais. Se isso acontecer com empate de especificidade, o roteador lança uma IllegalArgumentException na inicialização, com as duas rotas na mensagem, e quem escreveu as rotas decide. Outra solução, usada por vários frameworks, é uma regra de desempate mais fina, como preferir o padrão cujo primeiro segmento fixo aparece mais cedo, o que faria /tarefas/{id} vencer de forma deliberada, e não por acaso.
Exercício 3
O que o Alvo.de("/arquivos/%2e%2e/%2e%2e/etc/passwd").segmentos() devolve? Por que isso é perigoso para a rota de arquivos estáticos de "A Pedra de Acabamento", e onde a defesa deve ficar?
Ver resposta
✓ Resposta: Devolve [arquivos, .., .., etc, passwd]. %2e é o ponto codificado, e o decodificador faz exatamente o que deve: transforma %2e%2e em ... O perigo aparece quando um manipulador de arquivos estáticos juntar esses segmentos a uma pasta raiz, como Path.of("/srv/site").resolve("..").resolve("..")...: o .. sobe um nível de pasta, e o caminho final pode sair da pasta do site e chegar a /etc/passwd, um ataque clássico chamado travessia de diretório (path traversal). A codificação %2e é usada justamente para escapar de filtros ingênuos que procuram .. no texto cru do alvo, antes da decodificação. A defesa não deve ficar no Alvo, que representa fielmente o que o cliente pediu e é usado também por rotas que não tocam em arquivos. Ela deve ficar no manipulador de arquivos, depois da decodificação: resolver o caminho dentro da raiz, normalizar com Path.normalize(), que resolve os .., e verificar com startsWith(raiz) que o resultado continua dentro da raiz, respondendo 404 (ou 403) caso contrário. É o que "A Pedra de Acabamento" vai fazer, com um teste para este exato alvo.
Exercício 4
Acrescente à API uma rota GET /tarefas/busca?q=texto que devolva os títulos que contêm o texto, sem diferenciar maiúsculas, um por linha, e responda 400 se q estiver ausente. Ela entra em conflito com GET /tarefas/{id}?
Ver resposta
✓ Resposta: Com uma lista de títulos de exemplo, a rota fica assim:
rotas.get("/tarefas/busca", req -> {
String q = req.consulta("q").orElse("").toLowerCase();
if (q.isBlank()) return Resposta.erro(Status.REQUISICAO_INVALIDA, "informe ?q=");
return Resposta.texto(titulos.stream()
.filter(t -> t.toLowerCase().contains(q))
.collect(Collectors.joining("\n", "", "\n")));
});
Com os títulos "Estudar sockets", "Estudar records" e "Comprar pão", GET /tarefas/busca?q=estudar devolve as duas primeiras linhas, e GET /tarefas/busca devolve 400 Bad Request: informe ?q=. Não há conflito de registro, porque as formas são diferentes, /tarefas/busca e /tarefas/{}, e o roteador só recusa rotas com a mesma forma. Na busca, os dois padrões casam com /tarefas/busca, mas o fixo tem dois literais e o parametrizado tem um, então a busca vence, pela regra de especificidade, em qualquer ordem de registro. A consulta não participa do roteamento: ?q=... é lida pelo manipulador com req.consulta("q"), e a mesma rota atende com ou sem ela. Numa aplicação real, a busca usaria as tarefas do mapa e devolveria JSON, como listar.
Exercício 5
Escreva um método static Manipulador cronometrado(Manipulador interno) que devolva um manipulador que mede o tempo do interno e acrescenta à resposta o cabeçalho Server-Timing: total;dur=<milissegundos>. Que padrão de "Lições de Quem Veio Antes" é este, e como aplicá-lo ao roteador inteiro?
Ver resposta
✓ Resposta:
static Manipulador cronometrado(Manipulador interno) {
return req -> {
long t0 = System.nanoTime();
Resposta r = interno.atender(req);
long micros = (System.nanoTime() - t0) / 1_000;
return r.comCabecalho("Server-Timing", "total;dur=" + (micros / 1000.0));
};
}
Envolvendo um manipulador que dorme 20 ms, a resposta sai com Server-Timing: total;dur=21.075. É o padrão Decorator: um objeto com a mesma interface do que ele envolve, que repassa a chamada e acrescenta um comportamento, sem que o objeto envolvido saiba. Como o Roteador também é um Manipulador, basta passar cronometrado(rotas) ao servidor para medir todas as requisições de uma vez, sem tocar em nenhuma rota; e como o resultado é outro Manipulador, os decoradores se empilham, como registrado(cronometrado(rotas)). O cabeçalho Server-Timing é padronizado pelo W3C e aparece na aba de rede das ferramentas de desenvolvedor dos navegadores, junto da requisição. Se o interno lançar uma exceção, ela atravessa o decorador sem cabeçalho nenhum e o servidor a transforma num 500, o que é o comportamento desejado: o decorador não deve esconder falhas.