Crear tu primer REST API con Spring Boot
1. Resumen
Este artículo te guía para crear un REST API completo con Spring Boot, usando un ejemplo de gestión de Order (pedidos). Después de leerlo, podrás crear el proyecto, escribir controllers que manejen CRUD, devolver errores con códigos HTTP correctos, y escribir tests para la API usando MockMvc.
2. Inicializar el proyecto
Crea el proyecto a través de Spring Initializr con las siguientes dependencias:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
spring-boot-starter-web proporciona Tomcat embebido y Spring MVC. spring-boot-starter-validation se utiliza para validar el cuerpo de la solicitud (sección 5). El spring-boot-starter-test incluye JUnit 5, AssertJ, MockMvc.
Verifica que la aplicación se ejecute:
@SpringBootApplication
public class OrderApiApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApiApplication.class, args);
}
}
Ejecuta mvn spring-boot:run, y verás que Tomcat se inicia en el puerto 8080.
3. Definir el modelo
public class Order {
private Long id;
private String customerName;
private BigDecimal amount;
public Order(Long id, String customerName, BigDecimal amount) {
this.id = id;
this.customerName = customerName;
this.amount = amount;
}
// getters, setters
}
Este es un modelo simple que aún no está relacionado con JPA. El objetivo de este artículo es dominar primero la capa REST, y dejar la persistencia para otro artículo.
4. Escribir un Service para almacenar datos temporales
@Service
public class OrderService {
private final Map<Long, Order> store = new ConcurrentHashMap<>();
private final AtomicLong idSequence = new AtomicLong(0);
public Order create(Order order) {
long id = idSequence.incrementAndGet();
Order saved = new Order(id, order.getCustomerName(), order.getAmount());
store.put(id, saved);
return saved;
}
public List<Order> findAll() {
return new ArrayList<>(store.values());
}
public Optional<Order> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
public void deleteById(Long id) {
store.remove(id);
}
}
ConcurrentHashMap actúa como una “base de datos” temporal, permitiéndote ejecutar la demo sin necesidad de configurar una BD. AtomicLong genera IDs incrementales, siendo seguro cuando múltiples threads llaman a create.
Nota: Este almacenamiento de datos es solo para aprender; los datos se pierden al reiniciar la aplicación. En producción necesitas Spring Data JPA o algo equivalente.
5. Escribir un REST Controller
5.1. Endpoints para crear y obtener la lista
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<Order> create(@Valid @RequestBody OrderRequest request) {
Order order = new Order(null, request.customerName(), request.amount());
Order saved = orderService.create(order);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
@GetMapping
public List<Order> findAll() {
return orderService.findAll();
}
}
@RestController combina @Controller y @ResponseBody, devolviendo JSON en lugar de vistas. @RequestMapping("/api/orders") establece un prefijo común para todos los endpoints de la clase. @PostMapping recibe el body a través de @RequestBody; Spring convierte automáticamente JSON a OrderRequest gracias a Jackson que viene en spring-boot-starter-web.
OrderRequest es un record usado para validar entrada, separado de Order:
public record OrderRequest(
@NotBlank String customerName,
@NotNull @Positive BigDecimal amount
) {}
5.2. Endpoints para obtener por ID y eliminar
@GetMapping("/{id}")
public ResponseEntity<Order> findById(@PathVariable Long id) {
return orderService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
orderService.deleteById(id);
return ResponseEntity.noContent().build();
}
@PathVariable obtiene el valor de {id} de la URL. Cuando no se encuentra el pedido, la API devuelve 404 Not Found en lugar de null — este es un punto fácil de pasar por alto cuando es nuevo en REST API.
Intenta llamarlo con curl:
curl -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{"customerName":"Nguyen Van A","amount":150000}'
HTTP/1.1 201 Created
Content-Type: application/json
{"id":1,"customerName":"Nguyen Van A","amount":150000}
6. Manejo centralizado de errores
Si no se maneja, los errores de validación devolverán un response JSON por defecto difícil de leer. Usa @RestControllerAdvice para estandarizar:
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
return ResponseEntity.badRequest().body(errors);
}
}
Esta clase captura todos los MethodArgumentNotValidException lanzados por @Valid, agrupa los errores por campo y devuelve 400 Bad Request con un body legible:
{
"amount": "must be greater than 0",
"customerName": "must not be blank"
}
Nota: No dejes que la excepción por defecto de Spring se exponga en producción — puede exponer el stack trace y afectar la seguridad.
7. Escribir tests para el Controller
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldReturn201WhenCreateOrderSuccessfully() throws Exception {
Order saved = new Order(1L, "Tran Thi B", new BigDecimal("200000"));
given(orderService.create(any(Order.class))).willReturn(saved);
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerName":"Tran Thi B","amount":200000}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.customerName").value("Tran Thi B"));
}
@Test
void shouldReturn404WhenOrderNotFound() throws Exception {
given(orderService.findById(99L)).willReturn(Optional.empty());
mockMvc.perform(get("/api/orders/99"))
.andExpect(status().isNotFound());
}
}
@WebMvcTest solo carga la capa web (controller, filter, validation), sin inicializar el contexto completo, por lo que los tests se ejecutan rápidamente. @MockBean reemplaza OrderService real con un mock, permitiendo testear el controller independientemente de la lógica del servicio. Estos dos casos de prueba verifican tanto la ruta exitosa como la de error — es una práctica que deberías tener para cada endpoint.
Verifica además usando AssertJ en la capa de servicio:
@Test
void shouldGenerateIncrementalIdWhenCreatingMultipleOrders() {
OrderService service = new OrderService();
Order first = service.create(new Order(null, "A", BigDecimal.TEN));
Order second = service.create(new Order(null, "B", BigDecimal.ONE));
assertThat(first.getId()).isEqualTo(1L);
assertThat(second.getId()).isEqualTo(2L);
}
8. Errores comunes
- Olvidar
@RequestBody: Spring no vinculará JSON al objeto; los campos siempre seránnullsin un mensaje de error claro. - Devolver
nullen lugar deResponseEntity.notFound(): el cliente recibe200 OKcon bodynull, lo que fácilmente causa bugs en el frontend. - No validar entrada: La API acepta montos negativos, nombres vacíos… causando errores lógicos en capas inferiores que son difíciles de rastrear.
- Usar
@Controlleren lugar de@RestController: tienes que agregar@ResponseBodymanualmente en cada método, fácil de olvidar.
Nota: Si tu API sirve a múltiples clientes (móvil, web, socios), considera versionado desde el principio (por ejemplo
/api/v1/orders) para evitar cambios incompatibles posteriormente.
9. Conclusión
Has construido un REST API básico con Spring Boot: desde el modelo, servicio, controller, manejo centralizado de errores hasta tests con MockMvc. El siguiente paso sería reemplazar ConcurrentHashMap con Spring Data JPA para que los datos persistan realmente, y agregar manejo de @RestControllerAdvice para otras excepciones como EntityNotFoundException.