Extensão — A API das Trilhas: REST, Validação, Erros e OpenAPI

Extensão — A API das Trilhas: REST, Validação, Erros e OpenAPI

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

• • 28 min de leitura

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:

  1. 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.
  2. 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.

  1. Os códigos de status dizem o que aconteceu. 2xx é sucesso, 4xx é erro de quem pediu e 5xx é erro nosso. Um 200 com {"erro": "não encontrado"} no corpo é mentir para o protocolo: proxies, caches e bibliotecas cliente vão acreditar no 200.
  2. A representação é negociada. O corpo vem em JSON (Content-Type: application/json), e um erro vem em application/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.

  • Double e Integer, e não double e int. Com um primitivo, um JSON sem o campo distanciaKm não chega à validação: o Jackson 3, que o Spring Boot 4 usa, recusa a requisição inteira (Cannot map `null` into type `double`, porque FAIL_ON_NULL_FOR_PRIMITIVES vem ligado), e o cliente recebe um "JSON ilegível" sem a lista de campos. No Jackson 2 era pior: o campo virava 0.0 em 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 @NotNull dá 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 (null vira 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.
  • detail explica esta ocorrência.
  • instance identifica 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 torna List<@NaoVazio String> possível. As anotações do elemento não estão no componente, e sim no tipo do componente. Os nomes pontosDeInteresse[1] saem daí. O Hibernate Validator, a implementação real do Bean Validation, gera o caminho pontosDeInteresse[1].<list element>, e o Spring o encurta para pontosDeInteresse[1] ao montar o erro de campo, o mesmo formato da miniatura.
  • O switch com 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

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.

Comentários

Mais em Java

Provando que Funciona — Testes com JUnit e AssertJ
Provando que Funciona — Testes com JUnit e AssertJ

Como escrever testes unitários com JUnit 6 e AssertJ: a estrutura preparar…

Listas, Conjuntos e Mapas — o Collections Framework
Listas, Conjuntos e Mapas — o Collections Framework

O Collections Framework do Java: interfaces e implementações, ArrayList e a…

O Preço da Compatibilidade — Wildcards e Type Erasure
O Preço da Compatibilidade — Wildcards e Type Erasure

Wildcards e type erasure em Java: por que os genéricos são invariantes e os…