Spring Framework

Como construir APIs REST com @RestController no Spring MVC

Intermediário6 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

O Spring MVC serve JSON pelo mesmo DispatcherServlet que renderiza as views, com os message converters no lugar dos view resolvers. Este guia cobre o mapeamento de requisições e respostas, status codes e headers com ResponseEntity, validação de corpos e parâmetros, respostas de erro consistentes com ProblemDetail, negociação de conteúdo e CORS, além do RestClient e dos clientes HTTP interface do Spring 6.1 para chamar outras APIs.

O que você vai aprender

  • Mapear requisições e respostas JSON com @RestController, @RequestBody e HttpMessageConverter
  • Retornar os status codes corretos, headers Location e corpos vazios com ResponseEntity
  • Validar corpos de requisição e query parameters e transformar as falhas em respostas 400
  • Gerar erros application/problem+json consistentes com ProblemDetail e @RestControllerAdvice
  • Consumir outras APIs REST com RestClient e clientes HTTP interface declarativos

No Spring, uma API REST é o Spring MVC com um estágio de saída diferente: em vez de resolver uma view, o valor de retorno de um método do controller é serializado direto no corpo da resposta por um HttpMessageConverter, e os corpos das requisições são desserializados da mesma forma na entrada. Todo o resto — handler mapping, resolução de argumentos, interceptors, exception resolvers — é compartilhado com a parte que renderiza views. Este guia tem como alvo o Spring Framework 6.x com Jackson 2 para JSON e parte do princípio de que você sabe como o DispatcherServlet roteia uma requisição.

Mapeando requisições e respostas

@RestController
@RequestMapping(path = "/api/products", produces = MediaType.APPLICATION_JSON_VALUE)
public class ProductController {

    private final ProductService products;

    public ProductController(ProductService products) {
        this.products = products;
    }

    @GetMapping
    public List<ProductResponse> list(@RequestParam(required = false) String category,
                                      @RequestParam(defaultValue = "20") int limit) {
        return products.list(category, limit).stream().map(ProductResponse::from).toList();
    }

    @GetMapping("/{id}")
    public ProductResponse get(@PathVariable long id) {
        return products.find(id).map(ProductResponse::from)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
        Product created = products.create(request.name(), request.priceCents());
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(ProductResponse.from(created));   // 201 + Location
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable long id) {
        products.delete(id);
    }
}

public record CreateProductRequest(@NotBlank @Size(max = 120) String name,
                                   @Positive long priceCents) {}

public record ProductResponse(long id, String name, long priceCents) {
    static ProductResponse from(Product p) { return new ProductResponse(p.id(), p.name(), p.priceCents()); }
}

@RequestBody diz ao Spring para ler o corpo com um converter escolhido pelo Content-Type da requisição; @RestController faz cada valor de retorno sair por um converter escolhido pelo header Accept da requisição e pelo valor produces do mapeamento. Com o Jackson no classpath, o MappingJackson2HttpMessageConverter cuida de application/json nos dois sentidos. Records são tipos ideais para requisição e resposta: imutáveis, concisos, e o Jackson 2.12+ os desserializa pelo construtor canônico sem precisar de anotações.

Separe de propósito os tipos de requisição, de resposta e de domínio. Serializar uma entidade JPA diretamente vaza campos internos, arrasta associações lazy para a serialização e acopla o formato da sua API ao schema do banco de dados. Código de mapeamento é chato, mas é justamente ali que a compatibilidade da API fica protegida.

Status codes e headers com ResponseEntity

ResponseEntity<T> reúne status, headers e corpo. Seus builders cobrem os formatos mais comuns:

SituaçãoCódigoForma idiomática
Recurso criado201ResponseEntity.created(location).body(dto)
Aceito para processamento assíncrono202ResponseEntity.accepted().build()
Sucesso sem corpo204ResponseEntity.noContent().build() ou @ResponseStatus(NO_CONTENT) em um método void
Não modificado (GET condicional)304ResponseEntity.ok().eTag(tag) junto com WebRequest.checkNotModified(tag)
Erro do cliente com corpo problem4xxResponseEntity.status(HttpStatus.CONFLICT).body(problemDetail)

Não retorne ResponseEntity<Object> para passar um corpo de erro por fora do sistema de tipos; respostas de erro pertencem aos exception handlers, que mantêm os controllers no caminho feliz.

Validação

@Valid em um parâmetro @RequestBody executa o Jakarta Bean Validation sobre o objeto desserializado. Uma falha lança MethodArgumentNotValidException, que o Spring mapeia para 400 por padrão. Desde o Spring Framework 6.1, restrições colocadas diretamente em parâmetros @RequestParam, @PathVariable e @RequestHeader — @Min(1) int page — são validadas pelo próprio Spring MVC, que lança HandlerMethodValidationException, também um 400. Versões anteriores exigiam @Validated na classe do controller e geravam uma ConstraintViolationException a partir do proxy AOP de validação de métodos, que virava 500 se você não a tratasse; se você ainda encontra esse padrão, é legado.

@GetMapping("/search")
public List<ProductResponse> search(@RequestParam @Size(min = 2, max = 50) String q,
                                    @RequestParam(defaultValue = "1") @Min(1) int page) { … }

O Bean Validation cuida do formato. Regras de negócio — "o SKU deve ser único", "o preço não pode cair mais de 50%" — pertencem ao service, que lança uma exceção de domínio que um advice mapeia para 409 ou 422.

Respostas de erro consistentes com ProblemDetail

O Spring Framework 6.0 adicionou suporte de primeira classe ao formato problem details padronizado na RFC 9457 (que torna obsoleta a RFC 7807). Um objeto ProblemDetail é serializado como application/problem+json:

{
  "type": "https://api.example.com/problems/product-not-found",
  "title": "Product not found",
  "status": 404,
  "detail": "No product with id 42",
  "instance": "/api/products/42",
  "productId": 42
}

Conecte isso em um @RestControllerAdvice. Estender ResponseEntityExceptionHandler faz com que as próprias exceções do Spring — JSON malformado, parâmetros ausentes, falhas de validação, media types não suportados — gerem o mesmo formato de corpo:

@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(ProductNotFoundException.class)
    ProblemDetail notFound(ProductNotFoundException ex) {
        var problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Product not found");
        problem.setType(URI.create("https://api.example.com/problems/product-not-found"));
        problem.setProperty("productId", ex.id());
        return problem;                       // Spring sets the status from the ProblemDetail
    }

    @ExceptionHandler(DuplicateSkuException.class)
    ProblemDetail conflict(DuplicateSkuException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "SKU " + ex.sku() + " already exists");
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
            HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        var problem = ex.getBody();          // ProblemDetail prepared by Spring
        problem.setProperty("errors", ex.getFieldErrors().stream()
                .map(e -> Map.of("field", e.getField(), "message", String.valueOf(e.getDefaultMessage())))
                .toList());
        return handleExceptionInternal(ex, problem, headers, status, request);
    }
}

Para casos simples existem duas alternativas. Lançar uma ErrorResponseException (ou uma subclasse) de qualquer lugar já carrega um ProblemDetail junto, dispensando o handler. E o Spring Boot 3 consegue habilitar a renderização de problem details para as exceções nativas com uma única propriedade. Seja qual for a escolha, nunca deixe stack traces ou nomes de classes internas chegarem ao campo detail; registre isso no log do servidor com um correlation id e coloque o id no corpo se o suporte precisar dele. Colar uma resposta no formatador de JSON é um jeito rápido de conferir o formato do que você está realmente retornando.

Negociação de conteúdo e CORS

O atributo produces e o header Accept do cliente selecionam o converter da resposta. Adicionar um converter XML (o módulo XML do Jackson) permite que o mesmo controller sirva application/xml quando solicitado; para um cliente que não envia Accept, o padrão é o primeiro tipo que pode ser produzido. A negociação por extensão de caminho (/products.json) foi desligada por padrão no Spring 5.3, e o suffix-pattern matching por trás dela foi removido na 6.0, porque era fonte de problemas de segurança — fique com os headers ou habilite um query parameter de forma deliberada.

Os navegadores bloqueiam chamadas JavaScript cross-origin a menos que a API autorize explicitamente com headers CORS. Configure isso em um só lugar, e não método a método:

@Configuration
public class ApiCorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://app.example.com")
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

@CrossOrigin em um controller ou método existe para os casos pontuais. allowedOrigins("*") junto com allowCredentials(true) é rejeitado pelos navegadores e pelo Spring; use allowedOriginPatterns quando você realmente precisar de um curinga. Quando o Spring Security estiver presente, registre a configuração CORS nele também, para que as requisições preflight não sejam rejeitadas antes de chegar ao MVC.

Chamando outras APIs: RestClient e HTTP interfaces

O Spring Framework 6.1 introduziu o RestClient, um cliente síncrono e fluente que compartilha a infraestrutura de converters e funciona com qualquer ClientHttpRequestFactory — o HttpClient do JDK, Apache HttpClient 5, Reactor Netty ou Jetty.

@Configuration
public class ClientConfig {

    @Bean
    RestClient inventoryClient(RestClient.Builder builder) {   // Boot supplies the builder; else RestClient.builder()
        return builder
                .baseUrl("https://inventory.internal/api")
                .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                .requestInterceptor((request, body, execution) -> {
                    request.getHeaders().setBearerAuth(Tokens.current());
                    return execution.execute(request, body);
                })
                .build();
    }
}

@Service
public class InventoryGateway {

    private final RestClient client;

    public InventoryGateway(RestClient inventoryClient) { this.client = inventoryClient; }

    public StockLevel stockFor(String sku) {
        return client.get()
                .uri("/stock/{sku}", sku)
                .retrieve()
                .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
                    throw new UnknownSkuException(sku);         // default would throw HttpClientErrorException
                })
                .body(StockLevel.class);
    }

    public void reserve(String sku, int quantity) {
        client.post()
                .uri("/reservations")
                .contentType(MediaType.APPLICATION_JSON)
                .body(new ReservationRequest(sku, quantity))
                .retrieve()
                .toBodilessEntity();
    }
}

retrieve() lança subclasses de RestClientResponseException para 4xx e 5xx, a não ser que onStatus substitua esse comportamento; exchange() entrega a resposta crua quando você precisa de controle total. Timeouts e pool de conexões são propriedades da request factory, não do cliente — configure-os ali, sempre, porque os padrões do JDK não têm limite.

Para clientes com várias operações, uma HTTP interface elimina o boilerplate que sobra:

@HttpExchange("/api/stock")
public interface StockApi {
    @GetExchange("/{sku}")
    StockLevel get(@PathVariable String sku);

    @PostExchange
    ResponseEntity<Void> reserve(@RequestBody ReservationRequest request);
}

@Bean
StockApi stockApi(RestClient inventoryClient) {
    return HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(inventoryClient))
            .build()
            .createClient(StockApi.class);
}

O Spring gera um proxy que traduz cada chamada em uma requisição HTTP a partir das anotações. A mesma interface também pode ser implementada sobre o WebClient em aplicações reativas. O RestTemplate continua funcionando e não está deprecated, mas é coisa do passado; código novo deve usar o RestClient.

Erros comuns

Retornar entidades. Associações lazy disparam queries ou LazyInitializationException durante a serialização, relacionamentos bidirecionais entram em recursão infinita, e toda mudança de schema vira uma mudança de API. Mapeie para records de resposta.

200 para tudo. Retornar {"error": "not found"} com status 200 quebra todo cliente que verifica status codes, inclusive o monitoramento. Use o código que corresponde ao que de fato aconteceu.

Capturar exceções nos controllers. Cada controller acaba com seu próprio formato de erro, inconsistente com os demais. Um único @RestControllerAdvice dá o mesmo corpo a todos os endpoints.

Confiar no @Valid para regras de negócio. Unicidade e transições de estado precisam do service e do banco de dados; uma anotação de restrição não enxerga as outras linhas.

Chamadas de saída sem timeout. Uma única dependência lenta esgota o pool de threads de requisição. Configure timeouts de conexão e de leitura na request factory de todo RestClient.

Curinga de CORS com credenciais. Os navegadores rejeitam essa combinação; e, se funcionasse, permitir que qualquer origem enviasse cookies seria uma vulnerabilidade séria.

Negociação de conteúdo por extensão de caminho. Ataques de reflected file download e de content sniffing foram o motivo de ela ter sido desligada; não volte a ligá-la.

Principais pontos

  • @RestController faz os valores de retorno passarem por HttpMessageConverters; o Jackson cuida do JSON sem nenhuma configuração.
  • Use records dedicados para requisição e resposta, e ResponseEntity quando o status ou os headers carregam significado.
  • @Valid nos corpos e anotações de restrição nos parâmetros geram 400 para erros de formato; regras de negócio ficam no service.
  • ProblemDetail com um @RestControllerAdvice que estende ResponseEntityExceptionHandler produz corpos de erro RFC 9457 uniformes.
  • Configure o CORS de forma centralizada e nunca combine uma origem curinga com credenciais.
  • RestClient (6.1) e os clientes HTTP interface são a forma moderna de chamar outros serviços; sempre configure timeouts na request factory.

Fontes e leituras complementares

Perguntas frequentes

Qual é a diferença entre @Controller e @RestController?
@RestController é @Controller mais @ResponseBody no nível da classe. O valor de retorno de cada handler é escrito no corpo da resposta por um message converter, em vez de ser interpretado como nome de view. Um @Controller pode fazer o mesmo método a método adicionando @ResponseBody, o que é útil quando um mesmo controller serve tanto páginas quanto alguns endpoints JSON.
Devo retornar ResponseEntity em todos os endpoints?
Só quando você precisa definir o status ou os headers explicitamente. No caso comum do 200, um tipo de retorno simples, com um @ResponseStatus opcional, é mais curto, e o Spring o converte exatamente da mesma forma. Use ResponseEntity para 201 Created com header Location, 204 No Content, respostas condicionais e qualquer situação em que o status dependa do resultado.
Por que minha API retorna uma página de erro HTML em vez de JSON?
A exceção escapou dos resolvers do Spring MVC e chegou ao tratamento de erros padrão do servlet container. Adicione um @RestControllerAdvice com um @ExceptionHandler para essa exceção (ou um handler genérico que retorne ProblemDetail), ou estenda ResponseEntityExceptionHandler para que as próprias exceções de binding e conversão do Spring também gerem corpos problem+json.
O RestTemplate está deprecated?
Não, mas está em modo de manutenção e não recebe mais funcionalidades novas. O Spring Framework 6.1 introduziu o RestClient, uma API fluente e síncrona que usa a mesma infraestrutura de cliente HTTP, e ele é a escolha recomendada para código novo. O RestTemplate continua com suporte, e RestClient.create(restTemplate) permite migrar aos poucos mantendo os interceptors e converters que você já tem.