Nesta aula as trilhas e os relatos ganham uma API HTTP de verdade. Os DTOs são records, a validação é declarada com anotações, os erros saem no formato padrão application/problem+json, as listas são paginadas com um formato próprio e a documentação OpenAPI é gerada a partir do código.
Até aqui, só o nosso próprio código conversa com os bancos. Um aplicativo de celular, o site que faremos em breve ou um parceiro que queira listar as trilhas precisam de uma porta de entrada: endereços HTTP com regras claras sobre o que se envia, o que se recebe e o que acontece quando algo dá errado. No capstone escrevemos essa porta à mão, lendo bytes de um socket. Hoje ela será erguida sobre o Spring MVC, e a proposta é enxergar, em cada anotação, a peça do Farol que ela substitui.
REST em uma página
REST é um estilo de arquitetura, não um protocolo. Na prática do dia a dia, ele se resume a quatro combinados:
- Recursos têm endereços.
/api/v1/trilhasé a coleção de trilhas, e/api/v1/trilhas/3é a trilha 3. Os endereços são substantivos. O verbo fica no método HTTP. - Os métodos têm significado, definido pela RFC 9110, que você conheceu no Farol:
| Método | Em /trilhas |
Em /trilhas/{id} |
Seguro? | Idempotente? |
|---|---|---|---|---|
GET |
lista | detalhe | sim | sim |
POST |
cria (201 + Location) |
— | não | não |
PUT |
— | substitui por inteiro | não | sim |
DELETE |
— | remove (204) | não | sim |
Idempotente quer dizer que repetir a requisição deixa o servidor no mesmo estado. Repetir um PUT com o mesmo corpo não muda nada. Repetir um POST cria duas trilhas. É por isso que um cliente pode reenviar um PUT depois de um timeout sem medo, mas precisa pensar duas vezes antes de reenviar um POST.
- Os códigos de status dizem o que aconteceu.
2xxé sucesso,4xxé erro de quem pediu e5xxé erro nosso. Um200com{"erro": "não encontrado"}no corpo é mentir para o protocolo: proxies, caches e bibliotecas cliente vão acreditar no 200. - A representação é negociada. O corpo vem em JSON (
Content-Type: application/json), e um erro vem emapplication/problem+json, que veremos a seguir.
Versionamos pelo caminho, com /api/v1, porque é simples de ler, de rotear no nginx e de testar com curl. O Spring Framework 7 também oferece versionamento nativo por cabeçalho ou parâmetro. É uma boa opção, mas a versão no caminho continua sendo a mais fácil de explicar.
O contrato: DTOs em records
A regra mais importante de uma API é que a entidade JPA não é o contrato. Se o controller devolvesse a entidade Trilha de "Extensão — Objetos no Banco: JPA e Hibernate", qualquer refatoração interna mudaria o JSON que os clientes recebem. Um relacionamento LAZY explodiria em uma LazyInitializationException na serialização, já que desligamos o open session in view. E, pior, campos internos vazariam para fora. A API fala em DTOs (data transfer objects), e records foram feitos para isso.
Também é aqui que se decide o formato da duração. A entidade usa Duration, que o Jackson serializaria como "PT2H", um formato ISO-8601 correto que ninguém lê com prazer. O contrato público diz duracaoMin: um inteiro, em minutos. É explícito, portátil entre linguagens e impossível de interpretar errado. A conversão acontece em um único lugar.
A entrada, NovaTrilha, fica assim:
package br.com.exemplo.trilhas.api;
import br.com.exemplo.trilhas.Dificuldade;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
@Schema(description = "Dados para cadastrar ou substituir uma trilha")
public record NovaTrilha(
@Schema(example = "Lagoinha do Leste")
@NotBlank(message = "informe o nome")
@Size(max = 100, message = "no máximo {max} caracteres")
String nome,
@Schema(example = "Sul")
@NotBlank(message = "informe a região")
@Size(max = 50, message = "no máximo {max} caracteres")
String regiao,
@Schema(example = "4.5", description = "Distância total, em quilômetros")
@NotNull(message = "informe a distância")
@DecimalMin(value = "0.1", message = "no mínimo {value} km")
@DecimalMax(value = "100", message = "no máximo {value} km")
Double distanciaKm,
@NotNull(message = "informe a dificuldade")
Dificuldade dificuldade,
@Schema(example = "120", description = "Duração estimada, em minutos")
@NotNull(message = "informe a duração")
@Min(value = 1, message = "no mínimo {value} minuto")
@Max(value = 1440, message = "no máximo {value} minutos (um dia)")
Integer duracaoMin,
@Size(max = 20, message = "no máximo {max} pontos")
List<@NotBlank(message = "ponto sem nome") @Size(max = 100, message = "no máximo {max} caracteres") String>
pontosDeInteresse) {
public NovaTrilha {
if (pontosDeInteresse == null) pontosDeInteresse = List.of(); // ausente = lista vazia
// NÃO use List.copyOf aqui: ele lança exceção com elemento null ANTES de a validação rodar
}
}
Três decisões aparecem nesse record.
DoubleeInteger, e nãodoubleeint. Com um primitivo, um JSON sem o campodistanciaKmnão chega à validação: o Jackson 3, que o Spring Boot 4 usa, recusa a requisição inteira (Cannot map `null` into type `double`, porqueFAIL_ON_NULL_FOR_PRIMITIVESvem ligado), e o cliente recebe um "JSON ilegível" sem a lista de campos. No Jackson 2 era pior: o campo virava0.0em silêncio, e o erro dizia "no mínimo 0.1 km" quando o problema real é "você esqueceu o campo". Com o tipo envoltório, ausência énull, e@NotNulldá a mensagem certa. Em DTOs de entrada, prefira envoltórios. Nos de saída, primitivos servem bem.- Restrições nos elementos da lista.
List<@NotBlank String>é uma anotação de uso de tipo (TYPE_USE), que "Contratos à Vista — Imutabilidade, Programação Defensiva e JSpecify" mencionou de passagem. O validador entra no tipo genérico e valida cada elemento. - O construtor compacto não pode quebrar antes da validação. A ordem dos acontecimentos é: o Jackson constrói o record e, depois, o Bean Validation o examina. Se o construtor lançar uma exceção, o cliente recebe um "JSON ilegível" em vez da lista de campos inválidos. Normalize (
nullvira lista vazia), mas não valide ali.
A saída tem dois formatos. O detalhe é completo; a listagem é um resumo sem os pontos de interesse, porque a lista não os mostra e carregá-los seria o N+1 de "Extensão — Objetos no Banco: JPA e Hibernate":
package br.com.exemplo.trilhas.api;
import br.com.exemplo.trilhas.Dificuldade;
import br.com.exemplo.trilhas.Trilha;
import java.util.List;
public record TrilhaResposta(long id, String nome, String regiao, double distanciaKm,
Dificuldade dificuldade, int duracaoMin, List<String> pontosDeInteresse) {
/** Chamado DENTRO da transação: o List.copyOf força a carga da coleção LAZY enquanto há sessão. */
public static TrilhaResposta de(Trilha t) {
return new TrilhaResposta(t.getId(), t.getNome(), t.getRegiao(), t.getDistanciaKm(),
t.getDificuldade(), (int) t.getDuracao().toMinutes(), List.copyOf(t.getPontosDeInteresse()));
}
}
package br.com.exemplo.trilhas.api;
import br.com.exemplo.trilhas.Dificuldade;
/** Projeção por construtor: o Spring Data monta o SELECT só com estas colunas. */
public record ResumoDeTrilha(Long id, String nome, String regiao, double distanciaKm, Dificuldade dificuldade) {}
Falta a paginação. O Page do Spring Data não é um contrato: serializá-lo expõe detalhes internos (pageable, sort, empty...) que mudaram entre versões, e o próprio Spring Data avisa no log quando você faz isso. Definimos o nosso formato:
package br.com.exemplo.trilhas.api;
import org.springframework.data.domain.Page;
import java.util.List;
/** O formato de página da API. Estável, documentado, nosso. */
public record Pagina<T>(List<T> itens, int pagina, int tamanho, long totalDeItens, int totalDePaginas) {
public static <T> Pagina<T> de(Page<T> p) {
return new Pagina<>(p.getContent(), p.getNumber(), p.getSize(), p.getTotalElements(), p.getTotalPages());
}
}
Se preferir usar o formato do próprio Spring, ele oferece @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO), que troca o PageImpl por um PagedModel estável. É uma escolha legítima. A nossa deixa o contrato em português e sob nosso controle.
O serviço: onde mora a transação
O controller não fala com repositórios. Quem fala é um serviço, porque é nele que ficam as transações. Isso importa aqui por um motivo concreto: como o open session in view está desligado, a conversão para DTO precisa acontecer dentro da transação, enquanto a coleção LAZY ainda pode ser carregada. Por isso o serviço devolve DTOs, e não entidades.
Primeiro os métodos novos do repositório de "Extensão — O Banco que Evolui: Spring Data e Migrations com Flyway":
// Acrescentados à interface RepositorioDeTrilhas
Page<ResumoDeTrilha> findAllBy(Pageable pagina);
Page<ResumoDeTrilha> findByRegiao(String regiao, Pageable pagina);
boolean existsByNome(String nome);
Depois as exceções do domínio e o serviço:
package br.com.exemplo.trilhas;
public class TrilhaNaoEncontrada extends RuntimeException {
public TrilhaNaoEncontrada(long id) { super("Não existe trilha com id " + id + "."); }
}
package br.com.exemplo.trilhas;
public class NomeDeTrilhaRepetido extends RuntimeException {
public NomeDeTrilhaRepetido(String nome) { super("Já existe uma trilha chamada \"" + nome + "\"."); }
}
package br.com.exemplo.trilhas;
import br.com.exemplo.trilhas.api.NovaTrilha;
import br.com.exemplo.trilhas.api.Pagina;
import br.com.exemplo.trilhas.api.RelatoResposta;
import br.com.exemplo.trilhas.api.ResumoDeTrilha;
import br.com.exemplo.trilhas.api.TrilhaResposta;
import br.com.exemplo.trilhas.relatos.RelatosNoMongo;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.List;
@Service
public class ServicoDeTrilhas {
private final RepositorioDeTrilhas trilhas; // PostgreSQL, via Spring Data JPA
private final RelatosNoMongo relatos; // MongoDB, via Spring Data MongoDB
public ServicoDeTrilhas(RepositorioDeTrilhas trilhas, RelatosNoMongo relatos) {
this.trilhas = trilhas;
this.relatos = relatos;
}
@Transactional(readOnly = true)
public Pagina<ResumoDeTrilha> listar(String regiao, Pageable pagina) {
var pagina1 = (regiao == null) ? trilhas.findAllBy(pagina) : trilhas.findByRegiao(regiao, pagina);
return Pagina.de(pagina1);
}
@Transactional(readOnly = true)
public TrilhaResposta buscar(long id) {
return TrilhaResposta.de(carregar(id));
}
@Transactional
public TrilhaResposta criar(NovaTrilha nova) {
if (trilhas.existsByNome(nova.nome())) throw new NomeDeTrilhaRepetido(nova.nome());
var t = new Trilha(nova.nome(), nova.regiao(), nova.distanciaKm(), nova.dificuldade(),
Duration.ofMinutes(nova.duracaoMin()), nova.pontosDeInteresse());
return TrilhaResposta.de(trilhas.save(t)); // IDENTITY: o INSERT sai já no save, e o id vem junto
}
@Transactional
public TrilhaResposta substituir(long id, NovaTrilha nova) {
var t = carregar(id);
if (!t.getNome().equals(nova.nome()) && trilhas.existsByNome(nova.nome())) {
throw new NomeDeTrilhaRepetido(nova.nome());
}
t.alterar(nova.nome(), nova.regiao(), nova.distanciaKm(), nova.dificuldade(),
Duration.ofMinutes(nova.duracaoMin()), nova.pontosDeInteresse());
return TrilhaResposta.de(t); // sem save(): o dirty checking grava no commit
}
@Transactional
public void remover(long id) {
trilhas.delete(carregar(id));
}
public List<RelatoResposta> relatos(long trilhaId) {
if (!trilhas.existsById(trilhaId)) throw new TrilhaNaoEncontrada(trilhaId);
return relatos.findTop10ByTrilhaIdOrderByDataDesc(trilhaId).stream().map(RelatoResposta::de).toList();
}
private Trilha carregar(long id) {
return trilhas.findById(id).orElseThrow(() -> new TrilhaNaoEncontrada(id));
}
}
O método alterar é novo na entidade. Ele troca os campos e substitui o conteúdo da coleção de pontos (pontos.clear(); pontos.addAll(novos)), sem trocar a coleção inteira, que o Hibernate está vigiando. O RelatoResposta é um record simples (autorId, data, nota, texto) criado a partir do RelatoDocumento da aula anterior.
Uma nota sobre o existsByNome: ele dá uma mensagem amigável no caso comum, mas não é a garantia. Duas requisições simultâneas com o mesmo nome passam as duas pelo exists antes de qualquer uma gravar. A garantia é o UNIQUE da tabela, e o erro que ele produz também será traduzido para 409, logo adiante.
O controller: a tradução entre HTTP e o serviço
package br.com.exemplo.trilhas.api;
import br.com.exemplo.trilhas.ServicoDeTrilhas;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.List;
@RestController
@RequestMapping("/api/v1/trilhas")
@Tag(name = "Trilhas", description = "O catálogo de trilhas da ilha")
public class TrilhasController {
private final ServicoDeTrilhas servico;
public TrilhasController(ServicoDeTrilhas servico) {
this.servico = servico;
}
@GetMapping
@Operation(summary = "Lista as trilhas em ordem de nome, com paginação")
public Pagina<ResumoDeTrilha> listar(
@RequestParam(required = false) String regiao,
@RequestParam(defaultValue = "0") @Min(0) int pagina,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int tamanho) {
return servico.listar(regiao, PageRequest.of(pagina, tamanho, Sort.by("nome")));
}
@GetMapping("/{id}")
@Operation(summary = "Detalhes de uma trilha")
@ApiResponse(responseCode = "200", description = "A trilha")
@ApiResponse(responseCode = "404", description = "Não existe trilha com esse id",
content = @Content(mediaType = "application/problem+json",
schema = @Schema(implementation = ProblemDetail.class)))
public TrilhaResposta buscar(@PathVariable long id) {
return servico.buscar(id);
}
@PostMapping
@Operation(summary = "Cadastra uma trilha")
@ApiResponse(responseCode = "201", description = "Criada; o cabeçalho Location aponta para ela")
public ResponseEntity<TrilhaResposta> criar(@Valid @RequestBody NovaTrilha nova, UriComponentsBuilder uri) {
TrilhaResposta criada = servico.criar(nova);
var local = uri.path("/api/v1/trilhas/{id}").buildAndExpand(criada.id()).toUri();
return ResponseEntity.created(local).body(criada);
}
@PutMapping("/{id}")
@Operation(summary = "Substitui todos os dados de uma trilha")
public TrilhaResposta substituir(@PathVariable long id, @Valid @RequestBody NovaTrilha nova) {
return servico.substituir(id, nova);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "Remove uma trilha")
public void remover(@PathVariable long id) {
servico.remover(id);
}
@GetMapping("/{id}/relatos")
@Operation(summary = "Os dez relatos mais recentes da trilha")
public List<RelatoResposta> relatos(@PathVariable long id) {
return servico.relatos(id);
}
}
Compare com o Farol. O @GetMapping("/{id}") é o Padrao com um Parametro. O @PathVariable long id é o Alvo convertido, com a vantagem de que um /trilhas/abc já vira 400 automaticamente. O @RequestBody é o Corpo passado pelo Jackson. O ResponseEntity.created(...) é a Resposta com status 201 e cabeçalho Location. O UriComponentsBuilder injetado já sabe o esquema, o host e a porta da requisição atual, inclusive atrás de um proxy, quando configurado para isso. A estrutura é a mesma que você escreveu à mão, só que agora em forma de anotações.
Há dois níveis de validação, e vale saber quem faz cada um. O @Valid no corpo valida o record inteiro e, se algo falhar, lança MethodArgumentNotValidException. Já @Min e @Max direto nos parâmetros ativam a validação de método embutida no Spring MVC desde a versão 6.1, que lança HandlerMethodValidationException. Nenhum dos dois exige @Validated na classe.
Erros que se explicam: a RFC 9457
Todo cliente de API já recebeu um 500 com uma página HTML de erro, um {"error": true} ou um stack trace em JSON. A RFC 9457, sucessora da RFC 7807, define um formato padrão, o Problem Details:
{
"type": "https://trilhas.example.com/problemas/trilha-nao-encontrada",
"title": "Trilha não encontrada",
"status": 404,
"detail": "Não existe trilha com id 99.",
"instance": "/api/v1/trilhas/99"
}
Cada campo tem um papel.
typeé uma URI que identifica o tipo de problema. Clientes podem tomar decisões com base nela, e ela pode apontar para uma página que explica o erro.titleé um resumo humano do tipo, e não muda entre ocorrências.detailexplica esta ocorrência.instanceidentifica onde o problema aconteceu.- Campos extras são permitidos. Vamos acrescentar
erros, com a lista de campos inválidos.
O Content-Type é application/problem+json.
O Spring tem suporte nativo. Ele oferece a classe ProblemDetail e o ResponseEntityExceptionHandler, que já converte todas as exceções do próprio Spring MVC (método não suportado, JSON ilegível, parâmetro faltando, 404 de rota inexistente...) para esse formato. Nós o estendemos com as exceções do nosso domínio:
package br.com.exemplo.trilhas.api;
import br.com.exemplo.trilhas.NomeDeTrilhaRepetido;
import br.com.exemplo.trilhas.TrilhaNaoEncontrada;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.net.URI;
import java.util.List;
@RestControllerAdvice
public class TratadorDeErros extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(TratadorDeErros.class);
private static final String BASE = "https://trilhas.example.com/problemas/";
public record CampoInvalido(String campo, String mensagem) {}
@ExceptionHandler(TrilhaNaoEncontrada.class)
ProblemDetail naoEncontrada(TrilhaNaoEncontrada e) {
return problema(HttpStatus.NOT_FOUND, "trilha-nao-encontrada", "Trilha não encontrada", e.getMessage());
}
@ExceptionHandler(NomeDeTrilhaRepetido.class)
ProblemDetail nomeRepetido(NomeDeTrilhaRepetido e) {
return problema(HttpStatus.CONFLICT, "nome-repetido", "Nome já usado", e.getMessage());
}
/** A corrida que o existsByNome não pega: o UNIQUE do banco é a última palavra. */
@ExceptionHandler(DataIntegrityViolationException.class)
ProblemDetail violacao(DataIntegrityViolationException e) {
log.warn("Violação de integridade", e); // o detalhe vai para o log, NÃO para o cliente
return problema(HttpStatus.CONFLICT, "conflito", "Conflito com dados existentes",
"A operação viola uma regra de unicidade ou de referência.");
}
/** Corpo inválido: acrescenta a lista de campos ao ProblemDetail que o Spring já montou. */
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
List<CampoInvalido> erros = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> new CampoInvalido(fe.getField(), fe.getDefaultMessage()))
.toList();
ProblemDetail p = ex.getBody();
p.setType(URI.create(BASE + "dados-invalidos"));
p.setTitle("Dados inválidos");
p.setDetail("A requisição tem " + erros.size() + " campo(s) inválido(s).");
p.setProperty("erros", erros);
return handleExceptionInternal(ex, p, headers, status, request);
}
private static ProblemDetail problema(HttpStatus status, String tipo, String titulo, String detalhe) {
ProblemDetail p = ProblemDetail.forStatusAndDetail(status, detalhe);
p.setType(URI.create(BASE + tipo));
p.setTitle(titulo);
return p; // o Spring preenche "instance" com o caminho da requisição
}
}
O que esse código não faz também é deliberado. A mensagem da DataIntegrityViolationException traz o nome da constraint, da tabela e às vezes o valor. Isso é informação útil para um atacante e inútil para um cliente legítimo, então ela vai para o log, e o cliente recebe uma frase genérica. A mesma regra vale para qualquer 500: stack trace nunca sai na resposta.
Uma nota sobre a propriedade spring.mvc.problemdetails.enabled=true: ela liga um tratador padrão que usa esse mesmo formato. Como declaramos o nosso próprio ResponseEntityExceptionHandler, o padrão sai de cena, e não precisamos dela.
Por dentro da validação: um mini Bean Validation
O @NotBlank parece mágica até você escrever um validador. São poucas linhas de reflexão, com as ferramentas de "O Programa que se Examina — Anotações e Reflexão". A miniatura abaixo roda só com o JDK e imita o comportamento, inclusive a validação de elementos de lista:
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.AnnotatedParameterizedType;
import java.lang.reflect.AnnotatedType;
import java.lang.reflect.RecordComponent;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
// ---- As "restrições": anotações comuns, lidas por reflexão em tempo de execução ----
@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.RECORD_COMPONENT, ElementType.TYPE_USE})
@interface NaoVazio { String mensagem() default "não pode ficar em branco"; }
@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.RECORD_COMPONENT, ElementType.TYPE_USE})
@interface TamanhoMax { int value(); }
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.RECORD_COMPONENT)
@interface Obrigatorio {}
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.RECORD_COMPONENT)
@interface Entre { double min(); double max(); }
record CampoInvalido(String campo, String mensagem) {}
enum Dificuldade { FACIL, MODERADA, DIFICIL }
// ---- O DTO de entrada, anotado como faremos com Bean Validation ----
record NovaTrilha(
@NaoVazio @TamanhoMax(100) String nome,
@NaoVazio @TamanhoMax(50) String regiao,
@Obrigatorio @Entre(min = 0.1, max = 100) Double distanciaKm,
@Obrigatorio Dificuldade dificuldade,
@Obrigatorio @Entre(min = 1, max = 1440) Integer duracaoMin,
@TamanhoMax(20) List<@NaoVazio @TamanhoMax(100) String> pontosDeInteresse) {}
// ---- O validador: percorre os componentes do record e as anotações de cada um ----
List<CampoInvalido> validar(Record objeto) {
var erros = new ArrayList<CampoInvalido>();
for (RecordComponent componente : objeto.getClass().getRecordComponents()) {
Object valor;
try {
valor = componente.getAccessor().invoke(objeto);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
checar(componente.getName(), valor, componente.getAnnotations(), erros);
// List<@NaoVazio String>: as anotações do ELEMENTO moram no tipo genérico, não no componente
if (componente.getAnnotatedType() instanceof AnnotatedParameterizedType generico
&& valor instanceof List<?> lista) {
AnnotatedType tipoDoElemento = generico.getAnnotatedActualTypeArguments()[0];
for (int i = 0; i < lista.size(); i++) {
checar(componente.getName() + "[" + i + "]", lista.get(i), tipoDoElemento.getAnnotations(), erros);
}
}
}
return erros;
}
void checar(String campo, Object valor, java.lang.annotation.Annotation[] anotacoes, List<CampoInvalido> erros) {
for (var a : anotacoes) {
String problema = switch (a) {
case Obrigatorio _ when valor == null -> "é obrigatório";
case NaoVazio n when valor == null || valor.toString().isBlank() -> n.mensagem();
case TamanhoMax t when valor instanceof String s && s.length() > t.value() ->
"deve ter no máximo " + t.value() + " caracteres";
case TamanhoMax t when valor instanceof List<?> l && l.size() > t.value() ->
"deve ter no máximo " + t.value() + " itens";
case Entre e when valor instanceof Number n
&& (n.doubleValue() < e.min() || n.doubleValue() > e.max()) ->
"deve estar entre " + fmt(e.min()) + " e " + fmt(e.max());
default -> null; // anotação satisfeita (ou não é nossa)
};
if (problema != null) erros.add(new CampoInvalido(campo, problema));
}
}
String fmt(double d) { return d == Math.rint(d) ? String.valueOf((long) d) : String.valueOf(d); }
// ---- A resposta de erro no formato da RFC 9457 (application/problem+json) ----
String problema(String instancia, List<CampoInvalido> erros) {
String lista = erros.stream()
.map(e -> " {\"campo\": \"%s\", \"mensagem\": \"%s\"}".formatted(e.campo(), e.mensagem()))
.collect(Collectors.joining(",\n"));
return """
{
"type": "https://trilhas.example.com/problemas/dados-invalidos",
"title": "Dados inválidos",
"status": 400,
"detail": "A requisição tem %d campo(s) inválido(s).",
"instance": "%s",
"erros": [
%s
]
}""".formatted(erros.size(), instancia, lista);
}
void main() {
var boa = new NovaTrilha("Lagoinha do Leste", "Sul", 4.5, Dificuldade.MODERADA, 120,
List.of("Mirante", "Lagoa"));
IO.println("válida → " + validar(boa));
var ruim = new NovaTrilha(" ", "Sul", 0.0, null, 120, java.util.Arrays.asList("Mirante", "", null));
IO.println(problema("/api/v1/trilhas", validar(ruim)));
}
$ java MiniValidador.java
válida → []
{
"type": "https://trilhas.example.com/problemas/dados-invalidos",
"title": "Dados inválidos",
"status": 400,
"detail": "A requisição tem 5 campo(s) inválido(s).",
"instance": "/api/v1/trilhas",
"erros": [
{"campo": "nome", "mensagem": "não pode ficar em branco"},
{"campo": "distanciaKm", "mensagem": "deve estar entre 0.1 e 100"},
{"campo": "dificuldade", "mensagem": "é obrigatório"},
{"campo": "pontosDeInteresse[1]", "mensagem": "não pode ficar em branco"},
{"campo": "pontosDeInteresse[2]", "mensagem": "não pode ficar em branco"}
]
}
Os detalhes para reparar:
- Todos os erros de uma vez. O validador não para no primeiro problema: coleta tudo. O cliente corrige o formulário inteiro em uma rodada, e não em cinco.
getAnnotatedType()é a peça que tornaList<@NaoVazio String>possível. As anotações do elemento não estão no componente, e sim no tipo do componente. Os nomespontosDeInteresse[1]saem daí. O Hibernate Validator, a implementação real do Bean Validation, gera o caminhopontosDeInteresse[1].<list element>, e o Spring o encurta parapontosDeInteresse[1]ao montar o erro de campo, o mesmo formato da miniatura.- O
switchcom padrões e guardas (case NaoVazio n when ...) é o pattern matching de "Hierarquias Fechadas — Sealed Classes e Pattern Matching" trabalhando sobre anotações. Elas também são objetos, instâncias de proxies gerados pela JVM.
O Bean Validation real acrescenta muito: grupos de validação, restrições em cascata (@Valid em campos), mensagens com interpolação e tradução, e restrições customizadas com @Constraint. A ideia central, porém, é essa: anotações como dados, reflexão como motor.
OpenAPI: a documentação que não mente
Documentação escrita à mão envelhece no dia seguinte ao deploy. A especificação OpenAPI descreve uma API em um documento JSON ou YAML (endereços, parâmetros, corpos, respostas, esquemas), e o springdoc gera esse documento a partir do código, lendo as anotações do Spring MVC, os tipos dos records e as restrições do Bean Validation.
As dependências da aula:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>3.1.1</version> <!-- a linha 3.x é a do Spring Boot 4 -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
Sem nenhuma configuração, dois endereços passam a existir: /v3/api-docs, com o documento OpenAPI, e /swagger-ui.html, com uma interface que lista os endpoints e permite testá-los no navegador. O esquema de NovaTrilha sai mais ou menos assim (trecho resumido):
NovaTrilha:
type: object
description: Dados para cadastrar ou substituir uma trilha
required: [nome, regiao, distanciaKm, dificuldade, duracaoMin]
properties:
nome: { type: string, maxLength: 100, minLength: 1, example: Lagoinha do Leste }
distanciaKm: { type: number, format: double, minimum: 0.1, maximum: 100, example: 4.5 }
dificuldade: { type: string, enum: [FACIL, MODERADA, DIFICIL] }
duracaoMin: { type: integer, format: int32, minimum: 1, maximum: 1440, example: 120 }
pontosDeInteresse:
type: array
maxItems: 20
items: { type: string, maxLength: 100, minLength: 1 }
Repare que as restrições (maxLength, minimum, required) não foram escritas duas vezes. Elas saíram do @Size, do @DecimalMin e do @NotNull. O que a validação garante é exatamente o que a documentação promete, e isso é o que torna a documentação confiável. A partir desse documento, ferramentas como o OpenAPI Generator produzem clientes em TypeScript, Kotlin ou Python. O app Android da Trilha B vai consumir essa API.
Duas observações de produção. Primeiro, a interface do Swagger é uma ferramenta de desenvolvimento, e em produção se costuma desligá-la (springdoc.swagger-ui.enabled=false no perfil prod) ou pelo menos protegê-la. Segundo, o caminho que seguimos se chama code-first: o código gera o contrato. O oposto, contract-first, escreve o YAML primeiro e gera interfaces Java a partir dele. É melhor quando várias equipes precisam combinar o contrato antes de existir código. Para uma equipe só, o code-first costuma ser mais leve.
Testando a camada web
O @WebMvcTest sobe só a camada web: controllers, @RestControllerAdvice, conversores e validação, sem banco e sem serviços reais. O serviço vira um dublê do Mockito, como em "Dublês e Disciplina — Mockito e Testes que Não Mentem", e o MockMvcTester faz as requisições sem abrir porta nenhuma:
package br.com.exemplo.trilhas.api;
import br.com.exemplo.trilhas.ServicoDeTrilhas;
import br.com.exemplo.trilhas.TrilhaNaoEncontrada;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verifyNoInteractions;
@WebMvcTest(TrilhasController.class)
class TrilhasControllerTest {
@Autowired MockMvcTester mvc;
@MockitoBean ServicoDeTrilhas servico;
@Test
void trilhaInexistenteViraProblemDetail404() {
given(servico.buscar(99)).willThrow(new TrilhaNaoEncontrada(99));
assertThat(mvc.get().uri("/api/v1/trilhas/99"))
.hasStatus(HttpStatus.NOT_FOUND)
.hasContentType(MediaType.APPLICATION_PROBLEM_JSON)
.bodyJson().extractingPath("$.title").isEqualTo("Trilha não encontrada");
}
@Test
void corpoInvalidoListaTodosOsCamposENaoChegaAoServico() {
String json = """
{"nome": " ", "regiao": "Sul", "distanciaKm": 4.5, "dificuldade": "FACIL",
"duracaoMin": 90, "pontosDeInteresse": ["Mirante", ""]}""";
assertThat(mvc.post().uri("/api/v1/trilhas").contentType(MediaType.APPLICATION_JSON).content(json))
.hasStatus(HttpStatus.BAD_REQUEST)
.bodyJson().extractingPath("$.erros[*].campo").asArray()
.containsExactlyInAnyOrder("nome", "pontosDeInteresse[1]");
verifyNoInteractions(servico); // validação falhou ANTES de qualquer regra de negócio
}
@Test
void tamanhoDePaginaAcimaDoLimiteE400() {
assertThat(mvc.get().uri("/api/v1/trilhas?tamanho=500")).hasStatus(HttpStatus.BAD_REQUEST);
}
}
O segundo teste verifica duas coisas: a lista de erros e o fato de o serviço nunca ter sido chamado. É a prova de que a fronteira funciona: dados inválidos não entram no sistema. Os testes com banco de verdade, subindo PostgreSQL e MongoDB, ficam para daqui a duas aulas.
Com o servidor rodando, o diálogo esperado é este:
$ curl -i -X POST localhost:8080/api/v1/trilhas -H 'Content-Type: application/json' \
-d '{"nome":"Caminho do Ribeirão","regiao":"Sul","distanciaKm":5,"dificuldade":"FACIL","duracaoMin":100}'
HTTP/1.1 201
Location: http://localhost:8080/api/v1/trilhas/5
Content-Type: application/json
{"id":5,"nome":"Caminho do Ribeirão","regiao":"Sul","distanciaKm":5.0,"dificuldade":"FACIL","duracaoMin":100,"pontosDeInteresse":[]}
$ curl -s 'localhost:8080/api/v1/trilhas?regiao=Sul&tamanho=2'
{"itens":[{"id":5,"nome":"Caminho do Ribeirão",...},{"id":1,"nome":"Lagoinha do Leste",...}],
"pagina":0,"tamanho":2,"totalDeItens":3,"totalDePaginas":2}
A API funciona, e esse é justamente o problema: qualquer pessoa com curl pode apagar a Lagoinha do Leste.
Fontes e leituras recomendadas
- RFC 9457 — Problem Details for HTTP APIs: o formato de erro usado na aula, curto e legível.
- RFC 9110 — HTTP Semantics: métodos, idempotência e códigos de status, direto da fonte.
- Spring Framework — Error Responses:
ProblemDetail,ErrorResponseeResponseEntityExceptionHandler. - Jakarta Bean Validation 3.1: a especificação das anotações, com restrições em elementos de contêiner.
- springdoc-openapi: configuração, anotações e as propriedades para produção.
- OpenAPI Specification 3.1: o formato do documento que o springdoc gera.
Exercícios
Exercício 1
Um cliente envia {"nome":"Nova","regiao":"Sul","dificuldade":"FACIL","duracaoMin":60}, sem distanciaKm. O que acontece com o record atual, e o que aconteceria se distanciaKm fosse double primitivo?
Ver resposta
✓ Resposta: Com Double, o campo chega como null, o @NotNull falha e a resposta é 400 com {"campo":"distanciaKm","mensagem":"informe a distância"}. Com double primitivo, o Jackson 3 do Spring Boot 4 recusa o JSON inteiro (FAIL_ON_NULL_FOR_PRIMITIVES vem ligado), e a resposta é um "JSON ilegível" genérico, sem a lista de campos. Com o Jackson 2, ou com essa opção desligada, o campo viraria 0.0, e o erro seria "no mínimo 0.1 km", uma mensagem que engana o cliente. Em DTOs de entrada, o tipo envoltório é o que permite distinguir "ausente" de "zero".
Exercício 2
Acrescente um endpoint PATCH /api/v1/trilhas/{id}/duracao que recebe {"duracaoMin": 150} e altera só a duração. Qual record de entrada, qual status de resposta, e o PATCH é idempotente aqui?
Ver resposta
✓ Resposta: Um record NovaDuracao(@NotNull @Min(1) @Max(1440) Integer duracaoMin), um método de serviço @Transactional que carrega a trilha, chama um t.alterarDuracao(Duration.ofMinutes(...)) e devolve TrilhaResposta (status 200), e no controller @PatchMapping("/{id}/duracao") com @Valid @RequestBody. O PATCH não é idempotente por definição, mas este é: aplicar "duração = 150" duas vezes deixa o mesmo estado. Já um PATCH que dissesse "some 10 minutos" não seria.
Exercício 3
Por que o TratadorDeErros responde 409 com uma frase genérica para DataIntegrityViolationException, em vez de repassar e.getMessage()? E por que existe esse tratamento se o serviço já chama existsByNome?
Ver resposta
✓ Resposta: A mensagem da exceção expõe detalhes internos (nome da constraint, da tabela, às vezes os valores), que ajudam um atacante a mapear o banco e não ajudam o cliente, e por isso vão só para o log. O tratamento existe porque existsByNome seguido de save tem uma janela de corrida: duas requisições simultâneas passam pelo exists antes de qualquer uma gravar. Só o UNIQUE do banco é atômico, e o 409 genérico cobre exatamente esse caso raro.
Exercício 4
Um colega quer devolver Page<ResumoDeTrilha> direto no controller, "porque já vem pronto". Dê dois motivos para manter o Pagina<T>.
Ver resposta
✓ Resposta: Primeiro, o JSON do PageImpl é um detalhe de implementação do Spring Data. Ele inclui campos internos (pageable, sort, empty...) e já mudou entre versões, e o próprio Spring Data registra um aviso quando o serializamos, o que quebraria clientes em uma atualização de dependência. Segundo, o contrato da API precisa ser deliberado e documentado. O Pagina<T> tem cinco campos com nomes escolhidos, aparece limpo no OpenAPI e pode ser mantido estável mesmo se trocarmos a paginação por keyset (a Slice) no futuro.
Exercício 5
Na miniatura, acrescente uma anotação @Padrao(regex) para String, e use-a para exigir que regiao seja Norte, Sul, Leste, Oeste ou Centro.
Ver resposta
✓ Resposta: Declare @Retention(RUNTIME) @Target({RECORD_COMPONENT, TYPE_USE}) @interface Padrao { String value(); }, acrescente ao switch o caso case Padrao p when valor instanceof String s && !s.matches(p.value()) -> "valor não permitido"; e anote o componente com @NaoVazio @Padrao("Norte|Sul|Leste|Oeste|Centro") String regiao. String.matches exige que a string inteira case com a expressão, então "Sul" passa e "Sudoeste" não. No Bean Validation real, o equivalente é @Pattern(regexp = "..."). Uma alternativa melhor seria transformar a região em enum, como a dificuldade, e deixar o próprio Jackson recusar valores desconhecidos.