REST con Spring MVC y Spring HATEOAS: de CRUD a hipermedia
Índice
- Qué es REST y qué no lo es
- Modelo de dominio y persistencia con Spring Data JPA
- Capa HTTP con Spring MVC
- Representaciones hipermedia con Spring HATEOAS
- Evolución de la API sin romper clientes existentes
- Estado de recursos y links condicionales
- Conclusión
1. Qué es REST y qué no lo es
REST no es un estándar. Es un conjunto de restricciones arquitectónicas definidas por Roy Fielding que, cuando se aplican sobre HTTP, permiten construir sistemas evolucionables e interoperables.
Un error frecuente es asumir que exponer endpoints con GET, POST, PUT y DELETE ya es suficiente para hablar de REST. Fielding lo describe con precisión:
Si el motor del estado de la aplicación no está siendo conducido por hipertexto, no puede ser RESTful.
Lo que la mayoría de las implementaciones produce en realidad es RPC sobre HTTP: el cliente necesita saber de antemano las URIs, los métodos permitidos y las reglas de transición de estado. Eso genera acoplamiento. El cliente y el servidor deben coordinarse manualmente ante cualquier cambio.
Una API verdaderamente RESTful incluye en sus respuestas los links que indican qué operaciones son válidas desde el estado actual del recurso. El cliente navega la API siguiendo esos links, sin conocimiento previo hardcodeado.
2. Modelo de dominio y persistencia con Spring Data JPA
El punto de partida es una entidad Employee anotada con @Entity de Jakarta Persistence:
package payroll;
import java.util.Objects;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
@Entity
class Employee {
private @Id @GeneratedValue Long id;
private String name;
private String role;
Employee() {}
Employee(String name, String role) {
this.name = name;
this.role = role;
}
public Long getId() { return this.id; }
public String getName() { return this.name; }
public String getRole() { return this.role; }
public void setId(Long id) { this.id = id; }
public void setName(String name) { this.name = name; }
public void setRole(String role) { this.role = role; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Employee)) return false;
Employee employee = (Employee) o;
return Objects.equals(this.id, employee.id)
&& Objects.equals(this.name, employee.name)
&& Objects.equals(this.role, employee.role);
}
@Override
public int hashCode() {
return Objects.hash(this.id, this.name, this.role);
}
}
@Entity indica que la clase es una entidad JPA. @Id y @GeneratedValue marcan el campo de clave primaria y delegan su generación al proveedor de persistencia (Hibernate, en el caso de Spring Boot por defecto).
Para la capa de acceso a datos, Spring Data JPA permite declarar un repositorio extendiendo JpaRepository:
package payroll;
import org.springframework.data.jpa.repository.JpaRepository;
interface EmployeeRepository extends JpaRepository<Employee, Long> {}
Esto es suficiente para obtener operaciones de creación, lectura, actualización y eliminación, más paginación y ordenamiento. Spring Data genera la implementación en tiempo de arranque basándose en la interfaz declarada.
Para precargar datos al inicio de la aplicación se usa un CommandLineRunner declarado como @Bean:
@Configuration
class LoadDatabase {
private static final Logger log = LoggerFactory.getLogger(LoadDatabase.class);
@Bean
CommandLineRunner initDatabase(EmployeeRepository repository) {
return args -> {
log.info("Preloading " + repository.save(new Employee("Bilbo Baggins", "burglar")));
log.info("Preloading " + repository.save(new Employee("Frodo Baggins", "thief")));
};
}
}
Spring Boot ejecuta todos los CommandLineRunner luego de que el contexto de aplicación está cargado.
3. Capa HTTP con Spring MVC
La anotación @RestController combina @Controller y @ResponseBody: indica que el valor de retorno de cada método se serializa directamente al cuerpo de la respuesta HTTP (por defecto como JSON, usando Jackson).
@RestController
class EmployeeController {
private final EmployeeRepository repository;
EmployeeController(EmployeeRepository repository) {
this.repository = repository;
}
@GetMapping("/employees")
List<Employee> all() {
return repository.findAll();
}
@PostMapping("/employees")
Employee newEmployee(@RequestBody Employee newEmployee) {
return repository.save(newEmployee);
}
@GetMapping("/employees/{id}")
Employee one(@PathVariable Long id) {
return repository.findById(id)
.orElseThrow(() -> new EmployeeNotFoundException(id));
}
@PutMapping("/employees/{id}")
Employee replaceEmployee(@RequestBody Employee newEmployee, @PathVariable Long id) {
return repository.findById(id)
.map(employee -> {
employee.setName(newEmployee.getName());
employee.setRole(newEmployee.getRole());
return repository.save(employee);
})
.orElseGet(() -> repository.save(newEmployee));
}
@DeleteMapping("/employees/{id}")
void deleteEmployee(@PathVariable Long id) {
repository.deleteById(id);
}
}
Cuando un id no existe, se lanza EmployeeNotFoundException:
class EmployeeNotFoundException extends RuntimeException {
EmployeeNotFoundException(Long id) {
super("Could not find employee " + id);
}
}
Para traducir esa excepción a una respuesta HTTP 404 se usa @RestControllerAdvice:
@RestControllerAdvice
class EmployeeNotFoundAdvice {
@ExceptionHandler(EmployeeNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
String employeeNotFoundHandler(EmployeeNotFoundException ex) {
return ex.getMessage();
}
}
@RestControllerAdvice aplica el advice a todos los controladores. @ExceptionHandler lo filtra por tipo de excepción. @ResponseStatus define el código HTTP de la respuesta.
Con esto, GET /employees/99 devuelve HTTP 404 con el cuerpo Could not find employee 99.
4. Representaciones hipermedia con Spring HATEOAS
El CRUD implementado hasta aquí no es REST: el cliente necesita conocer las URIs de antemano. Spring HATEOAS introduce las construcciones necesarias para adjuntar links a las representaciones.
Dependencia a agregar en pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
EntityModel<T> es un contenedor genérico que combina el dato con una colección de links. CollectionModel<T> hace lo mismo para colecciones de recursos.
El método para obtener un recurso individual pasa de devolver Employee a devolver EntityModel<Employee>:
@GetMapping("/employees/{id}")
EntityModel<Employee> one(@PathVariable Long id) {
Employee employee = repository.findById(id)
.orElseThrow(() -> new EmployeeNotFoundException(id));
return EntityModel.of(employee,
linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel(),
linkTo(methodOn(EmployeeController.class).all()).withRel("employees"));
}
linkTo(methodOn(...)) construye el link usando reflexión sobre el controlador. withSelfRel() lo marca como el link canónico del recurso. withRel("employees") define la relación semántica del link hacia la raíz de la colección.
El JSON resultante sigue el formato HAL (Hypertext Application Language):
{
"id": 1,
"name": "Bilbo Baggins",
"role": "burglar",
"_links": {
"self": { "href": "http://localhost:8080/employees/1" },
"employees": { "href": "http://localhost:8080/employees" }
}
}
Para evitar duplicar la lógica de construcción de links en cada método del controlador, Spring HATEOAS provee la interfaz RepresentationModelAssembler:
@Component
class EmployeeModelAssembler
implements RepresentationModelAssembler<Employee, EntityModel<Employee>> {
@Override
public EntityModel<Employee> toModel(Employee employee) {
return EntityModel.of(employee,
linkTo(methodOn(EmployeeController.class).one(employee.getId())).withSelfRel(),
linkTo(methodOn(EmployeeController.class).all()).withRel("employees"));
}
}
Con @Component, Spring instancia el assembler al arrancar. El controlador lo inyecta por constructor y lo usa en cada método:
@GetMapping("/employees")
CollectionModel<EntityModel<Employee>> all() {
List<EntityModel<Employee>> employees = repository.findAll().stream()
.map(assembler::toModel)
.collect(Collectors.toList());
return CollectionModel.of(employees,
linkTo(methodOn(EmployeeController.class).all()).withSelfRel());
}
La respuesta de GET /employees incluye un link self a nivel de colección y cada elemento embebido tiene sus propios links.
5. Evolución de la API sin romper clientes existentes
Supongamos que el campo name debe separarse en firstName y lastName. Si simplemente se reemplaza el campo en la entidad, todos los clientes que lean o escriban name dejan de funcionar.
La estrategia correcta es mantener compatibilidad hacia atrás: agregar los nuevos campos sin eliminar el campo existente. Esto se implementa con getters y setters virtuales:
@Entity
class Employee {
private @Id @GeneratedValue Long id;
private String firstName;
private String lastName;
private String role;
Employee() {}
Employee(String firstName, String lastName, String role) {
this.firstName = firstName;
this.lastName = lastName;
this.role = role;
}
// Getter virtual para compatibilidad con clientes antiguos
public String getName() {
return this.firstName + " " + this.lastName;
}
// Setter virtual: parsea el campo name hacia firstName y lastName
public void setName(String name) {
String[] parts = name.split(" ");
this.firstName = parts[0];
this.lastName = parts[1];
}
public String getFirstName() { return this.firstName; }
public String getLastName() { return this.lastName; }
// ... resto de getters y setters
}
La respuesta JSON resultante expone los tres campos:
{
"id": 1,
"firstName": "Bilbo",
"lastName": "Baggins",
"role": "burglar",
"name": "Bilbo Baggins",
"_links": {
"self": { "href": "http://localhost:8080/employees/1" },
"employees": { "href": "http://localhost:8080/employees" }
}
}
Los clientes que sólo leen name siguen funcionando. Los nuevos clientes pueden usar firstName y lastName. Cuando se actualiza el servidor, no se necesita actualizar todos los clientes de forma simultánea.
Para devolver el código HTTP correcto al crear un recurso, el método POST utiliza ResponseEntity:
@PostMapping("/employees")
ResponseEntity<?> newEmployee(@RequestBody Employee newEmployee) {
EntityModel<Employee> entityModel = assembler.toModel(repository.save(newEmployee));
return ResponseEntity
.created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri())
.body(entityModel);
}
ResponseEntity.created() devuelve HTTP 201 y agrega el header Location con la URI del recurso recién creado, derivada del link self del modelo.
6. Estado de recursos y links condicionales
Cuando un recurso tiene un ciclo de vida con transiciones de estado, el enfoque HATEOAS permite que el servidor comunique al cliente qué operaciones son válidas en cada momento, en lugar de que el cliente deba conocer las reglas de negocio.
Definimos una entidad Order con un Status:
enum Status {
IN_PROGRESS,
COMPLETED,
CANCELLED
}
@Entity
@Table(name = "CUSTOMER_ORDER")
class Order {
private @Id @GeneratedValue Long id;
private String description;
private Status status;
// constructores, getters, setters, equals, hashCode
}
La anotación @Table(name = "CUSTOMER_ORDER") es necesaria porque ORDER es una palabra reservada en SQL estándar.
El OrderModelAssembler agrega links condicionalmente según el estado actual:
@Component
class OrderModelAssembler
implements RepresentationModelAssembler<Order, EntityModel<Order>> {
@Override
public EntityModel<Order> toModel(Order order) {
EntityModel<Order> orderModel = EntityModel.of(order,
linkTo(methodOn(OrderController.class).one(order.getId())).withSelfRel(),
linkTo(methodOn(OrderController.class).all()).withRel("orders"));
if (order.getStatus() == Status.IN_PROGRESS) {
orderModel.add(linkTo(methodOn(OrderController.class).cancel(order.getId())).withRel("cancel"));
orderModel.add(linkTo(methodOn(OrderController.class).complete(order.getId())).withRel("complete"));
}
return orderModel;
}
}
Los links cancel y complete sólo aparecen cuando la orden está en estado IN_PROGRESS. Un cliente que renderice botones basándose en los links presentes en la respuesta no necesita conocer las reglas de transición: si el link no existe, no muestra el botón.
La operación de cancelación verifica el estado antes de ejecutar la transición:
@DeleteMapping("/orders/{id}/cancel")
ResponseEntity<?> cancel(@PathVariable Long id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
if (order.getStatus() == Status.IN_PROGRESS) {
order.setStatus(Status.CANCELLED);
return ResponseEntity.ok(assembler.toModel(orderRepository.save(order)));
}
return ResponseEntity
.status(HttpStatus.METHOD_NOT_ALLOWED)
.header(HttpHeaders.CONTENT_TYPE, MediaTypes.HTTP_PROBLEM_DETAILS_JSON_VALUE)
.body(Problem.create()
.withTitle("Method not allowed")
.withDetail("You can't cancel an order that is in the " + order.getStatus() + " status"));
}
Si la transición no es válida, el servidor responde con HTTP 405 y un cuerpo en formato RFC-7807 (application/problem+json). Este formato estandariza la representación de errores en APIs HTTP.
La lógica de la operación complete es análoga, validando también que el estado sea IN_PROGRESS antes de transicionar a COMPLETED.
| Estado inicial | cancel | complete |
|---|---|---|
IN_PROGRESS |
✅ → CANCELLED |
✅ → COMPLETED |
COMPLETED |
❌ HTTP 405 | ❌ HTTP 405 |
CANCELLED |
❌ HTTP 405 | ❌ HTTP 405 |
7. Conclusión
La diferencia entre un CRUD sobre HTTP y una API REST está en el nivel de acoplamiento que se impone al cliente. Un CRUD requiere que el cliente conozca las URIs, los métodos válidos y las reglas de transición de estado fuera de banda. Una API REST comunica esa información en la propia respuesta, a través de links.
Spring HATEOAS provee las abstracciones (EntityModel, CollectionModel, RepresentationModelAssembler, WebMvcLinkBuilder) para construir estas representaciones sin hardcodear URIs en el código. El costo de configuración - principalmente implementar assemblers para cada tipo de recurso - se traduce en clientes más robustos frente a cambios en el servidor.
Las prácticas concretas que reducen la fragilidad de una API son:
- No eliminar campos de las representaciones; agregar los nuevos manteniendo los anteriores.
- Usar links basados en relaciones semánticas (
rel) en lugar de URIs hardcodeadas. - Mantener links anteriores el mayor tiempo posible para que clientes más viejos sigan funcionando.
- Comunicar operaciones válidas a través de links, no a través de documentación externa.
Comentarios (0)
Todavía no hay comentarios.
Dejá un comentario