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ção | Código | Forma idiomática |
|---|---|---|
| Recurso criado | 201 | ResponseEntity.created(location).body(dto) |
| Aceito para processamento assíncrono | 202 | ResponseEntity.accepted().build() |
| Sucesso sem corpo | 204 | ResponseEntity.noContent().build() ou @ResponseStatus(NO_CONTENT) em um método void |
| Não modificado (GET condicional) | 304 | ResponseEntity.ok().eTag(tag) junto com WebRequest.checkNotModified(tag) |
| Erro do cliente com corpo problem | 4xx | ResponseEntity.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
@RestControllerfaz os valores de retorno passarem porHttpMessageConverters; o Jackson cuida do JSON sem nenhuma configuração.- Use records dedicados para requisição e resposta, e
ResponseEntityquando o status ou os headers carregam significado. @Validnos corpos e anotações de restrição nos parâmetros geram 400 para erros de formato; regras de negócio ficam no service.ProblemDetailcom um@RestControllerAdviceque estendeResponseEntityExceptionHandlerproduz 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.