HATEOAS
Introducción
HATEOAS (Hypermedia as the Engine of Application State) es un principio de diseño de arquitecturas API REST donde cada recurso incluye en su respuesta enlaces a otros recursos relacionados. Este concepto está basado en el antiguo paradigma de Internet como red de información conectada entre sí mediante enlaces en páginas HTML.
Aplicado a servicios REST este principio permite descubrir, a partir de un único punto de entrada a un servicio, toda la información disponible relacionada con el recurso así como todas las acciones permitidas (consultar, crear, modificar, eliminar).
Con esta arquitectura se obtienen servicios autodescriptivos, se reduce la carga en servicios que devuelven gran cantidad de datos o se abstraen a los clientes de las rutas de los endpoints.
Algunos ejemplos de casos de uso son:
- Maestro detalle. Por ejemplo, un pedido con varios enlaces al endpoint de productos, en lugar de incluir todos los detalles de los productos en el payload del pedido.
- Paginación. Por ejemplo, devolver los 10 primeros elementos de un listado y un enlace al endpoint que devuelve la siguiente página, en lugar de obtener el listado completo de elementos.
- Informar sobre las acciones disponibles. Por ejemplo, en una petición GET a un recurso, incluir enlaces a los endpoints que permiten modificar o borrar dicho recurso.
¿Qué es un Recurso?. Un recurso hace referencia a un concepto importante de nuestro negocio (Facturas, Cursos, Compras, etc).
HATEOAS con JAX-RS
Para construir y añadir enlaces en los recursos REST, JAX-RS proporciona las clases Link (basada en la especificación RFC 5988) y UriBuilder.
Link
La clase Link es el pilar central de HATEOAS. Representa el enlace entre dos recursos y consta de las siguientes propiedades:
- rel - (String) Tipo de relación entre el recurso ubicado en href y el actual. Ver el subapartado Relaciones.
- href - (URI) URI que especifica el endpoint del recurso relacionado.
- type - (String) MediaType del recurso relacionado (“application/xml”,“application/json”, etc.).
- title - (String) Título o etiqueta usado para identificar la relación.
Ejemplo:
"link": { "type": "application/json", "title": "Hola Mundo", "rel": "self", "href": "http://localhost:8001/proyectoPrueba/rest/public/v1/saluda" }
En formato XML esta estructura se conoce también como Atom Link:
<link href="http://localhost:8001/proyectoPrueba/rest/public/v1/saluda" rel="self" type="application/xml" title="Hola Mundo"/>
La clase Link dispone de un Builder que permite instanciar objetos de forma clara y legible a partir de métodos estáticos.
Se ampliará información sobre el patrón Builder en el apartado correspondiente a la clase UriBuilder.
Los métodos más interesantes para crear Links y definir sus valores son:
- fromUri(String uri) - Crea un objeto Link.Builder a partir de un String que contiene una uri.
- fromUri(URI uri) - Crea un objeto Link.Builder a partir de un objeto URI.
- fromLink(Link link) - Crea un objeto Link.Builder a partir de los campos contenidos en otro Link.
- rel(String rel) - Completa el campo rel de un builder. Establece el tipo de relación entre recursos.
- title(String title) - Completa el campo title de un builder. Establece la etiqueta de la relación entre recursos.
- type(String type) - Completa el campo type de un builder. Establece el MediaType del endpoint al que apunta el enlace.
- build() - Construye un objeto Link a partir de los campos establecidos en su builder.
Relaciones
Aunque es posible etiquetar el tipo de relación (campo rel) de un Link como se desee, existe un estándar que pretende definir y registrar todos los posibles tipos de relaciones.
Algunas de las relaciones existentes más interesantes son:
- self: representa un enlace al recurso actual.
- edit: representa un enlace a los servicios que permiten modificar/eliminar el recurso actual.
- prev/next : representa un enlace a los recursos anteriores y posteriores, respectivamente, cuando se está navegando entre elementos en una lista.
- first/last : representa un enlace al primer y último recurso, respectivamente, cuando se está navegando entre elementos en una lista.
- item: representa un enlace a un elemento de una colección.
- related: representa un enlace con algún tipo de relación. Podría usarse como relación por defecto.
- search: representa un enlace al servicio de búsqueda del recurso actual.
Como se ha mencionado anteriormente, estas son algunas de las relaciones predefinidas y es recomendable su uso, pero es posible crear tipos ad hoc estableciendo el campo rel con la etiqueta que se desee.
URIBuilder
URIBuilder es una clase abstracta que implementa el patrón de diseño Builder y que, por lo tanto, proporciona una serie de métodos estáticos que permiten construir objetos de la clase URI de forma fácil y legible. Estos objetos URI constituyen la otra base principal de HATEOAS y permiten completar el campo href de los objetos Link vistos en el apartado anterior.
Algunos de los métodos más interesantes son:
- fromUri(String string) - Crea una instancia de URIBuilder a partir una URI pasada como String.
- fromUri(URI uri) - Crea una instancia de URIBuilder a partir un objeto URI (ver UriInfo).
- path(Class c) - Añade al URIBuilder el path definido en el servicio ubicado en la clase c.
- path(Class c, String method) - Añade al URIBuilder el path definido en el recurso ubicado en c.method.
- queryParam(String name, Object… values) - Añade a la URI un parámetro definido con @QueryParam en el recurso destino.
- build(Object… values) - Construye el objeto URI. Los valores values se corresponden con los parámetros en el path del recurso destino.
Para usar el método path(Class c, String method), es necesario haber usado previamente path(Class c).
Uno añade el path definido en el método, y el otro añade el path definido en la clase.
Se verán algunos ejemplos de uso de todos estos métodos en el siguiente apartado.
UriInfo
UriInfo es una clase proporcionada por JAX-RS que aporta información sobre las URIs de una aplicación o petición. Puede usarse junto a URIBuilder para crear URIs de forma rápida y sencilla.
Ejemplo:
//... @Context UriInfo uriInfo; //... // Crea una URI con la dirección base donde se publican los servicios en nuestra aplicación: http://localhost:8001/[APLICACION]/rest/ URI uri = UriBuilder.fromUri( uriInfo.getBaseUri() ).build( ); // Crea una URI con la dirección completa absoluta de la petición. URI uri = UriBuilder.fromUri( uriInfo.getAbsolutePath() ).build( ); // Devuelve un String con el path de la clase+método de la petición. String path = uriInfo.getPath();
UriInfo se declara con la anotación @Context y puede definirse a nivel de clase, como una propiedad más, o como parámetro en el método que se desee usar.
Uso de HATEOAS en FundeWeb
FundeWeb incorpora el soporte para las clases Link y UriBuilder desde las siguientes versiones de librerias:
- fundeweb-jaxrs-2.0.102
- fundeweb-java-services-config-2.0.102
- fundeweb-jersey-2.0.103
A fecha de redacción de esta WiKi solo es posible utilizar HATEOAS en aplicaciones desplegadas en WebLogic 12.2.
Si desea utilizar HATEOAS en aplicaciones desplegadas en WebLogic 12.1.3, póngase en contacto con MNCS.
Modo de Empleo
Una vez comentada la teoría de HATEOAS y conocidas las clases implicadas en su uso, a continuación se detalla como configurar nuestro servicio REST en FundeWeb para hacer uso de esta arquitectura.
Importante: Como se ha mencionado en la introducción, HATEOAS es un principio arquitectónico. Esto implica un cambio en la filosofía habitual de desarrollo, tanto en los propios servicios como en el modelo de objetos DTO usados. En el ejemplo siguiente se comenta esta situación.
Queda a criterio de los desarrolladores estimar o desestimar el uso de HATEOAS en función de las necesidades de su negocio. Consultar con MNCS en caso de duda.
Vamos a mostrar la configuración y uso partiendo de un ejemplo de servicio REST con dos recursos: Personas y Cargos.
1. Añadir Link a los DTOs
El primer paso es añadir tantas propiedades de la clase Link como enlaces queramos representar en nuestra respuesta:
PersonaDTO:
@JsonSerialize( include = Inclusion.NON_EMPTY ) @JsonPropertyOrder({"identificador", "correo", "nombre", "apellido", "link", "cargos","acciones" }) @JsonRootName( value = "personaPrueba" ) public class PersonaDTO { //... private Link link; private Link cargos; private List<Link> acciones; //... @XmlJavaTypeAdapter( Link.JaxbAdapter.class ) @JsonProperty @JsonSerialize(using = LinkSerializer.class) public Link getLink() { return link; } @JsonDeserialize(using = LinkDeserializer.class) public void setLink( Link link ) { this.link = link; } @XmlElementWrapper( name = "cargos" ) @XmlElement( name = "link" ) @XmlJavaTypeAdapter( Link.JaxbAdapter.class ) @JsonProperty @JsonSerialize(using = LinkSerializer.class) public Link getCargos() { return cargos; } @JsonDeserialize(contentUsing = LinkDeserializer.class) public void setCargos( Link cargos ) { this.cargos = cargos; } @XmlJavaTypeAdapter( Link.JaxbAdapter.class ) @XmlElementWrapper( name = "acciones" ) @XmlElement( name = "link" ) @JsonProperty @JsonSerialize(contentUsing = LinkSerializer.class) public List<Link> getAcciones() { if (acciones == null) { acciones = new ArrayList<>(); } return acciones; } @JsonDeserialize(contentUsing = LinkDeserializer.class) public void setAcciones( List<Link> acciones ) { this.acciones = acciones; } //.. }
Como se puede observar en el código, no es necesario declarar un List<CargoDTO> para representar los cargos de una persona.
Al utilizar el Link cargos, crearemos un enlace al endpoint que obtiene esta información.
Esto es un buen ejemplo del principio HATEOAS: en lugar de devolver una persona con toda sus campos y, en la misma respuesta, todos la información desglosada de los cargos que ocupa esa persona, se devuelve una persona con un enlace al recurso que obtiene sus cargos.
Otra aproximación diferente habría podido ser crear una lista de Link para representar la lista de cargos de una persona, y que cada Link apunte al endpoint que obtiene los detalles de un cargo específico (GET /cargos/{id}).
No olvidar activar el mapeo de objetos a JSON como se indica en la WiKi REST con JSON.
Sino utilizamos las anotaciones JAXB, y si las anotaciones de Jackson, para serializar podemos usar.
@JsonProperty @JsonSerialize(contentUsing = LinkSerializer.class) public Link getLink() { return link; }
2. Definir el servicio
En este segundo paso se define el servicio para el recurso de Personas con todos los métodos que vamos a enlazar posteriormente.
Servicio:
//... @Path( "/public/v1/personas" ) public class PersonasREST { //... /** * Proporciona información sobre las URIs del servicio y de la request solicitada. */ @Context UriInfo uriInfo; //... /** * Método GET que obtiene la persona con correo "correo" */ @GET @Path( "/{correo}" ) @Produces( {MediaType.APPLICATION_JSON, ProblemMediaType.APPLICATION_PROBLEM_JSON } ) public Response getPersona( @Email @PathParam( "correo" ) String correo ) { //... } /** * Método POST que crea una Persona "persona". */ @POST @Consumes(MediaType.APPLICATION_JSON) @Produces( {MediaType.APPLICATION_JSON, ProblemMediaType.APPLICATION_PROBLEM_JSON } ) public Response createPersona( PersonaDTO persona ) { //... } /** * Método PUT que actualiza una Persona con correo "correo" con los datos de "persona". */ @PUT @Path( "/{correo}" ) @Consumes(MediaType.APPLICATION_JSON) @Produces( {MediaType.APPLICATION_JSON, ProblemMediaType.APPLICATION_PROBLEM_JSON } ) public Response updatePersona( @Email @PathParam( "correo" ) String correo, PersonaDTO persona ) { //... } /** * Método DELETE que borra la persona con correo "correo". */ @DELETE @Path( "/{correo}" ) @Produces( {MediaType.APPLICATION_JSON, ProblemMediaType.APPLICATION_PROBLEM_JSON } ) public Response deletePersona( @Email @PathParam( "correo" ) String correo ) { //... } /** * Método GET que devuelve todos los cargos de la persona con correo "correo". */ @GET @Path("/{correo}/cargos") @Produces( {MediaType.APPLICATION_JSON, ProblemMediaType.APPLICATION_PROBLEM_JSON } ) public Response getCargosPersona(@Email @PathParam( "correo" ) String correo) { //... } }
Como se puede observar, existe un método para realizar todas las operaciones CRUD y otro método para obtener todos los cargos de una persona determinada.
El método createPersona no necesita Path, se hace una petición POST directamente al Path del servicio. Ver WiKi Buenas prácticas con servicios REST.
3. Definir los enlaces
En este último paso vamos a ver de forma detallada el método getPersona y algunas formas diferentes de construir enlaces usando URIBuilder.
Método GET:
@GET @Path( "/{correo}" ) @Produces( {MediaType.APPLICATION_JSON, ProblemMediaType.APPLICATION_PROBLEM_JSON} ) public Response getPersona( @Email @PathParam( "correo" ) String correo ) { PersonaDTO persona = null; Persona entity = null; try { // se obtiene la persona desde el servicio de Gente entity = servicioGenteUmu.getPersonaByUsuario( EnvironmentManagerBean.instance().getApplicationName(),correo ); // convierte el objeto Persona de Gente a PersonaDTO persona = PersonaDTO.fromPersona( entity );
- Creación de un objeto Link a partir de una Uri construida con UriInfo.getAbsolutePath(). Se establece el resto de valores del enlace con los métodos vistos en Link.
Ejemplo típico de enlace “self”.
//Enlace "self" usando Link.fromUri y uriInfo.getAbsolutePath() que obtiene la ruta completa del servicio solicitado. Link linkPersona = Link.fromUri( uriInfo.getAbsolutePath() ).rel( "self" ) .type( MediaType.APPLICATION_JSON ).title( "Persona" ).build(); persona.setLink( linkPersona );
- Creación del Link al endpoint de los cargos de la persona. Ejemplo de uso de UriInfo.getBaseUri(), obtiene el path de la aplicación, del método path(Class c), obtiene la notación path de la clase, y de path(Class c, String method) obtiene la notación path del método.
//UriBuilder a partir de baseUri (path rest de la aplicación) y path del servicio. UriBuilder ubPersonas = UriBuilder.fromUri( uriInfo.getBaseUri() ).path( PersonasREST.class ); //Enlace a cargos a partir del path del método getCargosPersona. Link linkCargos = Link.fromUri( ubPersonas.clone().path( PersonasREST.class, "getCargosPersona" ).build(correo)).rel( "cargos" ) .type( MediaType.APPLICATION_JSON ).title( "Cargos" ).build(); persona.setCargos( linkCargos );
- Creación de los Link a las distintas operaciones CRUD sobre el recurso. Ejemplo de uso de Link.fromLink, crea un Link.Builder a partir de los parámetros de un Link. Para reutilizar el UriBuilder ubPersonas es necesario usar el método clone(), ya que cada llamada a path() en un UriBuilder concatena el valor existente con el parámetro pasado, quedando el objeto modificado. Si no se clonara se irían anexando paths uno tras otro con cada llamada.
//Enlaces a las distintas operaciones disponibles sobre el recurso Personas. // GET mediante Link.fromLink para reutilizar link existente y cambiarle algún valor. persona.addAccion( Link.fromLink( linkPersona ).title("GET").build( ) ); // POST sin path ya que el endpoint coincide con el path del servicio persona.addAccion( Link.fromUri( ubPersonas.clone().build()).rel("edit").type( MediaType.APPLICATION_JSON ).title( "POST" ).build() ); persona.addAccion( Link.fromUri( ubPersonas.clone().path( getClass(), "updatePersona" ).build(correo )).rel("edit") .type( MediaType.APPLICATION_JSON ).title( "PUT" ).build() ); persona.addAccion( Link.fromUri( ubPersonas.clone().path( getClass(), "deletePersona" ).build(correo )).rel("edit") .type( MediaType.APPLICATION_JSON ).title( "DELETE" ).build() );
- Captura de excepciones y creación del Problem que describe el error. Ver WiKi Manejo de errores en servicios REST.
} catch ( PersonaException | PersonaNotFoundException e ) { log.error( "Se ha producido un error al buscar el usuario [#0].", e, correo ); GenericProblem gp = ( GenericProblem ) GenericProblem.fromStatusDetail( Response.Status.NOT_FOUND,"No existe el usuario: " + correo ); gp.getParameters().put( "documento", correo ); throw new NotFoundException( gp ); } return Response.ok( persona ).build(); }
4. Ejemplo de petición
A continuación se muestra un ejemplo de petición GET al recurso Persona explicado anteriormente y el resultado que produce la llamada.
Petición:
http://ramongg.atica.um.es:8001/proyectoPrueba/rest/public/v1/personas/ramongg@um.es
Respuesta:
{ "correo": "ramongg@um.es", "nombre": "RAMON", "apellido": "GINEL GEA", "link": { "type": "application/json", "rel": "self", "title": "Persona", "href": "http://ramongg.atica.um.es:8001/proyectoPrueba/rest/public/v1/personas/ramongg@um.es" }, "cargos": { "type": "application/json", "rel": "cargos", "title": "Cargos", "href": "http://ramongg.atica.um.es:8001/proyectoPrueba/rest/public/v1/personas/ramongg@um.es/cargos" }, "acciones": [ { "type": "application/json", "rel": "self", "title": "GET", "href": "http://ramongg.atica.um.es:8001/proyectoPrueba/rest/public/v1/personas/ramongg@um.es" }, { "type": "application/json", "rel": "edit", "title": "POST", "href": "http://ramongg.atica.um.es:8001/proyectoPrueba/rest/public/v1/personas" }, { "type": "application/json", "rel": "edit", "title": "PUT", "href": "http://ramongg.atica.um.es:8001/proyectoPrueba/rest/public/v1/personas/ramongg@um.es" }, { "type": "application/json", "rel": "edit", "title": "DELETE", "href": "http://ramongg.atica.um.es:8001/proyectoPrueba/rest/public/v1/personas/ramongg@um.es" } ] }
Referencias y Bibliografía
FAQs
URIBuilder.path con CORS
Si se está utilizando CORS y se quiere construir un enlace a un método que tiene su equivalente @OPTIONS, el método path(Class c, String method) fallará al encontrar dos métodos nombrados de la misma manera.
(FundeWeb 2)
Solución
Renombrar el método anotado con @OPTIONS. ¡OJO! solo hay que renombrar el método Java, no hay que modificar el @Path del recurso ni ningún otro parámetro.
— RAMON GINEL GEA 20/02/2020 14:37
Solicitud/Registro de FAQ
Pon aquí tus propuestas de FAQs, indicando qué problema tienes, y buscaremos la solución lo antes posible. Si además lo has resuelto, puedes indicar cómo lo has hecho.
Formato de petición de FAQ:
**Título** //Descripción del problema// //Tecnología afectada (Fundeweb 1/2)// //Cómo reproducir el error// **Solución** //Descripción de la solución// Tus datos de contacto --- //[[correo@umOticarum.es|Tu Nombre]] dd/mm/yyyy //
— RAMON GINEL GEA 17/02/2020 12:57
- fdw2.0/fundeweb2.0/gt/rest/hateoas.txt
- Última modificación: 14/01/2021 12:38
- por JUAN MIGUEL BERNAL GONZALEZ