Java EE / Jakarta EE
Jakarta REST (JAX-RS 3.1): crear y consumir APIs HTTP
Intermedio9 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Jakarta RESTful Web Services (JAX-RS) es la API de la plataforma, basada en anotaciones, para servicios HTTP, y el modelo que implementan Jersey, RESTEasy y Apache CXF. Esta guía muestra cómo se enrutan las peticiones hacia los métodos de recurso, cómo se enlazan los parámetros y los cuerpos JSON, cómo devolver los códigos de estado y cuerpos de error adecuados, cómo filtros, interceptores y exception mappers añaden comportamiento transversal y cómo llamar a otros servicios con la API cliente, junto con los errores de diseño y de seguridad que aparecen en APIs reales.
Lo que aprenderás
- Declarar una aplicación y clases de recurso, y entender cómo se asocian las peticiones a ellas
- Enlazar parámetros de ruta, de query, de cabecera y de cuerpo, y devolver objetos Response con los códigos de estado correctos
- Convertir excepciones en respuestas de error y aplicar Bean Validation a las entradas
- Añadir filtros, interceptores y providers para autenticación, logging y tratamiento del contenido
- Consumir servicios con la API cliente de Jakarta REST y usar respuestas asíncronas y Server-Sent Events
Jakarta RESTful Web Services —JAX-RS en cualquier conversación, y en el nombre del paquete hasta el cambio a jakarta.ws.rs— traslada HTTP a métodos Java normales y corrientes. Anotas una clase con una ruta y sus métodos con verbos HTTP, declaras de dónde sale cada parámetro y devuelves un objeto; el runtime se ocupa del enrutamiento, la negociación de contenido, la serialización y las respuestas de error. Mientras que la API de servlets expone HTTP tal cual es, Jakarta REST lo expone tal como lo piensa quien diseña una API: recursos, representaciones, códigos de estado y tipos de medio. Los principios de diseño de una buena API HTTP pertenecen a la materia de arquitectura; esta guía trata de aplicarlos bien con esta API.
Aplicación, recursos y matching
Una clase de recurso es una clase normal, normalmente un bean CDI, con un @Path. Sus métodos llevan una anotación de método HTTP, opcionalmente una subruta y la declaración de lo que consumen y producen. Una única subclase de Application con @ApplicationPath fija la raíz de la API y, si la dejas vacía, registra todos los recursos y providers que encuentre el runtime.
package shop.api;
import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.core.Application;
@ApplicationPath("/api") // all resources live under /<context>/api
public class ShopApplication extends Application { }
package shop.api;
import java.net.URI;
import java.util.List;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
@Path("/orders")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequestScoped
public class OrderResource {
@Inject OrderService orders;
@Context UriInfo uriInfo; // request-scoped injection of URI details
@GET
public List<OrderSummary> list(@QueryParam("status") OrderStatus status,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("20") int size) {
return orders.list(status, page, size);
}
@GET
@Path("/{id}")
public OrderDetail get(@PathParam("id") long id) {
return orders.find(id).orElseThrow(NotFoundException::new); // 404
}
@POST
public Response create(@Valid NewOrder request) {
OrderDetail created = orders.place(request);
URI location = uriInfo.getAbsolutePathBuilder().path(Long.toString(created.id())).build();
return Response.created(location).entity(created).build(); // 201 + Location
}
@PATCH
@Path("/{id}/status")
public OrderDetail changeStatus(@PathParam("id") long id, StatusChange change) {
return orders.changeStatus(id, change.status());
}
@DELETE
@Path("/{id}")
public void cancel(@PathParam("id") long id) {
orders.cancel(id); // void -> 204 No Content
}
}
El matching se hace por etapas: el runtime elige la clase de recurso cuyo @Path coincide con el prefijo más largo, después un método cuya subruta y verbo HTTP coincidan, y luego filtra comparando el Content-Type de la petición con @Consumes y la cabecera Accept con @Produces. Si la ruta coincide pero ningún verbo lo hace, el resultado es 405 Method Not Allowed; si el verbo coincide pero no hay tipo de medio compatible, 415 Unsupported Media Type o 406 Not Acceptable. Las plantillas de ruta admiten expresiones regulares —@Path("/{id: \\d+}")— para que /orders/abc sea un 404 y no un error de conversión. Un método con @Path pero sin anotación de verbo es un sub-resource locator: devuelve otro objeto de recurso que continúa el matching, y así es como se extrae /orders/{id}/lines a su propia clase.
Las clases de recurso que no son beans CDI tienen ámbito de petición por defecto; declarar @RequestScoped o @ApplicationScoped de forma explícita deja claro el ciclo de vida y permite que CDI inyecte dependencias en ellas. Un recurso con ámbito de aplicación debe ser thread-safe, y sus campos @Context son proxies que se resuelven en cada petición.
Parámetros y cuerpos
Los parámetros proceden de varias fuentes, cada una con su anotación: @PathParam, @QueryParam, @HeaderParam, @CookieParam, @FormParam (cuerpos de formulario URL-encoded) y @MatrixParam. La conversión desde la cadena admite tipos primitivos, enums, cualquier tipo con un constructor String o un método estático valueOf/fromString, y List<T> para valores repetidos; @DefaultValue cubre los parámetros ausentes. Un grupo de parámetros relacionados puede agruparse en una clase con los campos anotados e inyectarse con @BeanParam, lo que mantiene cortas las firmas de los endpoints de búsqueda.
El único parámetro sin anotar es el cuerpo de la entidad. Lo lee un MessageBodyReader elegido según el Content-Type: los cuerpos JSON se convierten en objetos mediante JSON Binding, String, byte[], InputStream y File se admiten de forma nativa, y Jakarta REST 3.1 por fin estandarizó el multipart con EntityPart:
@POST
@Path("/{id}/attachments")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response attach(@PathParam("id") long id, List<EntityPart> parts) throws IOException {
for (EntityPart part : parts) {
if (part.getFileName().isPresent()) {
orders.attach(id, part.getFileName().get(), part.getMediaType(), part.getContent());
}
}
return Response.noContent().build();
}
Para las clases clásicas, JSON Binding necesita un constructor público sin argumentos y getters/setters o campos públicos; los records los admite Yasson 3, la implementación compatible de JSON-B, desde JSON-B 3.0. El nombre de los campos, los formatos de fecha, el tratamiento de null y el orden de las propiedades se controlan con @JsonbProperty, @JsonbDateFormat, @JsonbNillable y @JsonbPropertyOrder, o de forma global con un JsonbConfig registrado mediante un ContextResolver<Jsonb>. Mantén los tipos de la API (NewOrder, OrderDetail) separados de las entidades JPA: serializar una entidad recorre las asociaciones lazy, filtra campos internos y acopla tu API a tu esquema.
Respuestas y códigos de estado
Un método que devuelve un objeto produce 200 OK con la entidad serializada, void produce 204 No Content y null produce también un 204. Todo lo demás pasa por Response:
| Situación | Se construye con | Estado |
|---|---|---|
| Recurso creado | Response.created(uri).entity(body) | 201 con Location |
| Aceptado para procesarse en segundo plano | Response.accepted() | 202 |
| Nada que devolver | Response.noContent() | 204 |
| GET condicional satisfecho | request.evaluatePreconditions(etag) devuelve un valor no nulo | 304 |
| Error del cliente con cuerpo | Response.status(Status.CONFLICT).entity(problem) | 409, etc. |
Las cabeceras se añaden con .header(...), la caché con .cacheControl(...) y .tag(...), y Response.temporaryRedirect(uri) cubre las redirecciones. Response también puede llevar una GenericEntity para devolver una List<T> conservando la información de tipo cuando el runtime no puede inferirla. Los códigos de estado significan algo para los clientes y para los intermediarios; la materia web incluye una referencia de códigos de estado para comprobar su semántica exacta antes de elegir uno.
Errores: excepciones y mappers
Lanzar una subclase de WebApplicationException —NotFoundException, BadRequestException, ForbiddenException, NotAuthorizedException, ClientErrorException(Response)— envía el estado correspondiente. Para tener un cuerpo de error coherente en toda la API, registra un ExceptionMapper por tipo de excepción:
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
@Provider
public class DomainExceptionMapper implements ExceptionMapper<DomainException> {
@Override
public Response toResponse(DomainException e) {
var problem = new Problem("https://api.example.com/problems/" + e.code(),
e.getMessage(), e.status());
return Response.status(e.status())
.type("application/problem+json") // RFC 9457 problem details
.entity(problem)
.build();
}
}
Las clases @Provider se descubren automáticamente. El runtime elige el mapper de la superclase más cercana a la excepción lanzada, así que un mapper para Throwable captura todo lo que se les escapa a los demás, y debería registrar la stack trace en el log y devolver un 500 genérico sin el mensaje de la excepción, que puede contener SQL o rutas internas. El runtime convierte los fallos de Bean Validation (ConstraintViolationException) en 400 cuando afectan a los datos de la petición; añade tu propio mapper si quieres que el cuerpo enumere las violaciones. Nunca dejes que una PersistenceException llegue al cliente sin mapear.
Validación
Anota los campos de los DTO con restricciones de Bean Validation y el parámetro con @Valid, como en create más arriba; las restricciones directamente sobre los parámetros (@QueryParam("size") @Max(100) int size) también funcionan. La validación se ejecuta antes del método, en la misma cadena de interceptores que todo lo demás, así que un método de recurso puede dar por hecho que sus entradas son estructuralmente válidas y centrarse en las reglas de negocio. La validación de valores de retorno (@Valid sobre el método) existe, pero rara vez compensa su coste.
Filtros, interceptores y otros providers
El comportamiento transversal vive en providers:
ContainerRequestFilterse ejecuta antes del método de recurso: autenticación, IDs de correlación, rate limiting. Anótalo con@PreMatchingpara que se ejecute antes del enrutamiento (necesario para reescribir el método o la URI) o déjalo post-matching para que pueda inspeccionar el recurso elegido a través deResourceInfo. Aborta la petición conrequestContext.abortWith(Response.status(401).build()).ContainerResponseFilterse ejecuta después: cabeceras CORS y de seguridad, logging de respuestas.ReaderInterceptor/WriterInterceptorenvuelven los streams de la entidad: compresión, firma, cifrado.MessageBodyReader/MessageBodyWriterañaden tipos de medio que el runtime no conoce, como CSV o Protocol Buffers.ParamConverterProviderenseña al runtime a convertir parámetros a tus propios tipos, como unMoneyo unOrderId.
Por defecto, los filtros se aplican de forma global. Para limitarlos a métodos concretos, define un name binding: una anotación meta-anotada con @NameBinding que se coloca tanto en el filtro como en los métodos de recurso que lo necesitan, de modo que @Secured en un método engancha el filtro de autenticación solo ahí. El orden entre filtros se controla con @Priority y las constantes de jakarta.ws.rs.Priorities (AUTHENTICATION se ejecuta antes que AUTHORIZATION, y después vienen HEADER_DECORATOR, ENTITY_CODER y USER).
La seguridad en sí se delega: con Jakarta Security configurado en el servidor, @RolesAllowed en un método de recurso (un enterprise bean, o un bean CDI en los servidores que lo admiten) o securityContext.isUserInRole("admin") desde un SecurityContext inyectado hacen la comprobación; en las APIs basadas en tokens, un filtro de petición valida el bearer token e instala su propio SecurityContext. La guía de JWT cubre la parte de los tokens.
La API cliente
Jakarta REST incluye un cliente fluido para llamar a otros servicios, que además es la base del Rest Client con tipado seguro de MicroProfile:
import jakarta.ws.rs.client.*;
import jakarta.ws.rs.core.*;
Client client = ClientBuilder.newBuilder()
.connectTimeout(2, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build(); // heavyweight: create once, reuse, close at shutdown
WebTarget orders = client.target("https://shop.example.com/api/orders");
try (Response response = orders.path("{id}").resolveTemplate("id", 42)
.request(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + token)
.get()) {
if (response.getStatus() == 200) {
OrderDetail order = response.readEntity(OrderDetail.class);
}
}
OrderDetail created = orders.request()
.post(Entity.json(new NewOrder("C-17", List.of(new Line("SKU-1", 2)))), OrderDetail.class);
WebTarget es inmutable y reutilizable; Invocation.Builder fija las cabeceras de cada llamada. Leer la entidad directamente (get(OrderDetail.class)) lanza WebApplicationException ante estados de error, algo cómodo en un script y molesto en un servicio: es mejor leer la Response y tratar los estados de forma explícita. Configura siempre timeouts: en la mayoría de las implementaciones son infinitos por defecto. El cliente admite los mismos filtros y providers que el servidor, además de la invocación reactiva (.rx().get(...), que devuelve un CompletionStage).
Respuestas asíncronas y Server-Sent Events
Un método de recurso puede liberar el thread del contenedor mientras espera: declara un parámetro @Suspended AsyncResponse, devuelve void y llama a asyncResponse.resume(result) desde otro thread, ya sea un managed executor o un message listener. setTimeout junto con un TimeoutHandler evita que las peticiones se queden colgadas para siempre. Es la cara Jakarta REST del procesamiento asíncrono de servlets, y solo compensa cuando la espera depende de algo externo.
Los Server-Sent Events transmiten una secuencia de eventos sobre una única respuesta de larga duración, algo que encaja con notificaciones y actualizaciones de progreso sin necesidad de WebSocket:
@GET
@Path("/{id}/events")
@Produces(MediaType.SERVER_SENT_EVENTS)
public void events(@PathParam("id") long id, @Context SseEventSink sink, @Context Sse sse) {
subscriptions.register(id, status ->
sink.send(sse.newEventBuilder().name("status").data(status).mediaType(MediaType.APPLICATION_JSON).build()));
}
SseBroadcaster reparte un mismo evento entre muchos sinks. Recuerda que cada sink abierto es una conexión retenida: configura un timeout de inactividad en el balanceador de carga y cierra los sinks cuando el cliente desaparezca.
Bootstrap en Java SE
Jakarta REST 3.1 añadió SeBootstrap para que una aplicación pueda ejecutarse sin contenedor de servlets: SeBootstrap.start(new ShopApplication(), SeBootstrap.Configuration.builder().port(8080).build()) arranca el servidor embebido de la implementación. Resulta práctico para herramientas y tests, aunque en un despliegue Jakarta EE el anfitrión sigue siendo el contenedor de servlets.
Errores comunes
- Exponer entidades JPA. Excepciones de lazy loading durante la serialización, campos filtrados y una API calcada del esquema. Mapea a DTO.
- Devolver 200 con un cuerpo de error. Los clientes no pueden fiarse de los códigos de estado y la monitorización no puede contar los fallos. Lanza una excepción y mapéala.
- Un mapper que lo captura todo y devuelve
e.getMessage(). Filtra detalles internos. Registra el error en el log del servidor y devuelve un problema genérico. - Ningún timeout en el cliente. Un servicio downstream atascado agota tu pool de threads. Configura timeouts de conexión y de lectura, y plantéate usar circuit breakers.
- Crear un
Clientpor petición. Es caro y deja conexiones abiertas. Uno por aplicación, cerrado al apagarla. @Pathen una clase sin ámbito. Funciona, pero el ciclo de vida depende de la implementación y puede que la inyección CDI no funcione. Declara@RequestScopedo@ApplicationScoped.- Confiar en
@Consumespara validar la entrada. Solo comprueba el tipo de medio. Añade Bean Validation y comprobaciones de negocio. - Usar
GETpara acciones. Las cachés y los mecanismos de precarga acabarán llamándolo. UsaPOSTpara todo lo que tenga efectos secundarios.
Puntos clave
- Una aplicación con
@ApplicationPathy clases de recurso con@Pathbastan para tener una API completa; el matching tiene en cuenta la ruta, el verbo y los tipos de medio, en ese orden, y las discrepancias producen automáticamente 404, 405, 415 o 406. - Enlaza las entradas con
@PathParam,@QueryParam,@HeaderParam,@BeanParamy un cuerpo sin anotar; JSON Binding se encarga del JSON, y la 3.1 estandarizó el multipart conEntityPart. - Devuelve objetos para los 200 simples y
Responsepara todo lo demás; usacreated(uri),noContent()y las precondiciones en lugar de construir estados a mano. - Lanza excepciones y tradúcelas en providers
ExceptionMapper, idealmente a problem details de RFC 9457; nunca filtres mensajes internos. - Filtros, interceptores, readers, writers y convertidores de parámetros son los puntos de extensión; los name bindings y
@Prioritycontrolan dónde y en qué orden se aplican. - La API cliente es fluida y reutilizable: un solo
Clientpor aplicación, timeouts explícitos y tratamiento explícito de los estados. @Suspended AsyncResponse, los Server-Sent Events ySeBootstrapcubren las esperas largas, el streaming y el uso independiente.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Qué implementación de Jakarta REST debería usar?
- La que traiga tu runtime: Jersey en GlassFish y Payara, RESTEasy en WildFly y Quarkus, la implementación de Liberty (basada en RESTEasy) en Open Liberty y Apache CXF en TomEE. El código escrito conforme a la especificación funciona en todas. Elige una implementación de forma deliberada solo para un uso independiente, sobre Tomcat o con la API de bootstrap de Java SE.
- ¿Cómo convierte Jakarta REST mi objeto en JSON?
- Mediante un MessageBodyWriter. Los servidores Jakarta EE incluyen uno respaldado por JSON Binding (JSON-B), así que cualquier clase con getters o campos públicos se serializa automáticamente, y anotaciones como @JsonbProperty y @JsonbTransient permiten ajustarla. Los despliegues independientes tienen que añadir por su cuenta un provider JSON (Yasson para JSON-B, o Jackson).
- ¿Un método de recurso debe devolver la entidad o una Response?
- Devuelve la entidad cuando el éxito sea siempre un 200 con cuerpo; el framework se encarga de la serialización y de la negociación de contenido. Devuelve una Response cuando necesites otro estado, cabeceras como Location o un resultado condicional. Nunca devuelvas una Response solo para envolver un error: lanza una excepción y deja que un ExceptionMapper genere el cuerpo de error de forma coherente.
- ¿Jakarta REST es lo mismo que el @RestController de Spring?
- La misma idea, con anotaciones y contenedor distintos. Ambos asocian métodos HTTP y rutas a métodos Java, enlazan parámetros y serializan JSON. Jakarta REST es el estándar que implementan varios fabricantes y funciona en cualquier servidor Jakarta EE; Spring MVC es propio de Spring. Los runtimes MicroProfile como Quarkus y Helidon usan Jakarta REST, lo que lo convierte en la habilidad más portable entre stacks Java.