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ónSe construye conEstado
Recurso creadoResponse.created(uri).entity(body)201 con Location
Aceptado para procesarse en segundo planoResponse.accepted()202
Nada que devolverResponse.noContent()204
GET condicional satisfechorequest.evaluatePreconditions(etag) devuelve un valor no nulo304
Error del cliente con cuerpoResponse.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:

  • ContainerRequestFilter se ejecuta antes del método de recurso: autenticación, IDs de correlación, rate limiting. Anótalo con @PreMatching para 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 de ResourceInfo. Aborta la petición con requestContext.abortWith(Response.status(401).build()).
  • ContainerResponseFilter se ejecuta después: cabeceras CORS y de seguridad, logging de respuestas.
  • ReaderInterceptor / WriterInterceptor envuelven los streams de la entidad: compresión, firma, cifrado.
  • MessageBodyReader / MessageBodyWriter añaden tipos de medio que el runtime no conoce, como CSV o Protocol Buffers.
  • ParamConverterProvider enseña al runtime a convertir parámetros a tus propios tipos, como un Money o un OrderId.

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 Client por petición. Es caro y deja conexiones abiertas. Uno por aplicación, cerrado al apagarla.
  • @Path en 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 @RequestScoped o @ApplicationScoped.
  • Confiar en @Consumes para validar la entrada. Solo comprueba el tipo de medio. Añade Bean Validation y comprobaciones de negocio.
  • Usar GET para acciones. Las cachés y los mecanismos de precarga acabarán llamándolo. Usa POST para todo lo que tenga efectos secundarios.

Puntos clave

  • Una aplicación con @ApplicationPath y clases de recurso con @Path bastan 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, @BeanParam y un cuerpo sin anotar; JSON Binding se encarga del JSON, y la 3.1 estandarizó el multipart con EntityPart.
  • Devuelve objetos para los 200 simples y Response para todo lo demás; usa created(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 @Priority controlan dónde y en qué orden se aplican.
  • La API cliente es fluida y reutilizable: un solo Client por aplicación, timeouts explícitos y tratamiento explícito de los estados.
  • @Suspended AsyncResponse, los Server-Sent Events y SeBootstrap cubren 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.
Jakarta Servlet 6: servlets, filtros, sesiones y la capa webCómo gestiona HTTP el contenedor de servlets: ciclo de vida, petición y respuesta, filtros, listeners, sesiones, modo asíncrono y novedades de Servlet 6.0.Principiante10 min de lecturaEnterprise Beans 4: session beans stateless, stateful y singletonQué aportan los session beans EJB (pooling, transacciones, seguridad, timers, llamadas asíncronas), en qué se diferencian y cuándo conviene más un bean CDI.Intermedio10 min de lecturaArquitecturaDiseño de APIs REST: recursos, semántica HTTP, versionado y formatos de errorCómo diseñar APIs HTTP que envejezcan bien: modelado de recursos, métodos y códigos de estado, paginación, versionado, errores RFC 9457 y ETags.Intermedio12 min de lecturaWeb y móvilCódigos de estado HTTP: referencia completa con su significado y uso correctoTodos los códigos de estado HTTP del 100 al 511: qué significan, los pares que se confunden, redirecciones, caché y pautas para diseñar APIs.Principiante17 min de lecturaSeguridadProteger APIs REST con JWT: verificación, claims y revocaciónVerifica JWT con seguridad: estructura JWS, lista blanca de algoritmos, rotación JWKS, validación de claims, revocación, almacenamiento y defensas de la API.Avanzado9 min de lecturaSpringCómo construir APIs REST con @RestController en Spring MVCAPIs JSON con @RestController, ResponseEntity y Jackson: validación, errores RFC 9457 con @RestControllerAdvice y llamadas a servicios con RestClient.Intermedio6 min de lectura