Affichage des articles dont le libellé est Jersey. Afficher tous les articles
Affichage des articles dont le libellé est Jersey. Afficher tous les articles

dimanche 29 janvier 2012

Jersey custom parameter, annotation and exception mapping

Jersey is the JSR-311 reference implementation for JAX-RS (Java API for RESTful Web Services). One of the drawback of this API is its lack of documentation when you want to go deeper into some complex or recurrent issues. Because we all like KISS code, let see how to keep Jersey simple, stupid!

Let's take the following example where we want to :
  1. Map Java Exception to specific HTTP response with localized messages
  2. Inject parameters of any type with custom validation
  3. Define a specialized annotation for specific injectable parameters
A first shot with no optimization will look like this :
@Path("/test")
@Produces("application/json")
public class TestEndpoint {
    
    private static final Logger log = Logger.getLogger(TestEndpoint.class.getName());
    
    private static final ResourceBundle resource = 
        ResourceBundle.getBundle("com.blogspot.avianey");
    
    @Inject TestService service;
    
    @POST
    @Path("{pathParam: \\d+}")
    public String testMethod(
            @PathParam("pathParam") Long id,
            @FormParam("date") String dateStr,
            @Context HttpServletRequest request) {

        // verifying that the user is logged in
        User user = (User) request.getSession().getAttribute("user");
        if (user == null) {
            throw new WebApplicationException(Status.UNAUTHORIZED);
        }
        
        // verifying the format of the sent date
        Date date = null;
        SimpleDateFormat sdf = new SimpleDateFormat("dd/MM/yyyy");
        try {
            date = sdf.parse(dateStr);
        } catch (ParseException pe) {
            throw new WebApplicationException(Status.BAD_REQUEST);
        }
        
        try {
            // calling the business logic
            return service.testOperation(id, date, user);
        } catch (BusinessException boe) {
            log.log(Level.FINER, "Business problem while executing testMethod", boe);
            throw new WebApplicationException(
                    Response.status(Status.BAD_REQUEST)
                            .type(MediaType.TEXT_PLAIN)
                            .entity(resource.getString(boe.getMessage()))
                            .build());
        } catch (TechnicalException te) {
            log.log(Level.FINER, "Technical problem while executing testMethod", te);
            throw new WebApplicationException(
                    Response.status(Status.INTERNAL_SERVER_ERROR)
                            .type(MediaType.TEXT_PLAIN)
                            .entity(resource.getString("error.internal"))
                            .build());
        }
        
    }
    
}
It's easy to think about the number of duplicated lines of code we would have if such a solution is applied for all our exposed methods... Now it's time to go deeper into Jersey and JAX-RS.

Custom Exception mapping with Jersey

In a Three-Tier architecture, the Logic-Tier may throw an application specific BusinessException, the Data-Tier may throw a TechnicalException, and so on... As BusinessException are relative to business rule violations it might be interresting to alert the user with a comprehensive message in the Presentation-Tier. On the contrary, a synthetic message will be displayed to the user for TechnicalException that refers to problems that are not likely to happen.

Jersey makes it possible to bind Java Exception with specialized HTTP response. All we have to do is to register an ExceptionMapper for each Java Exception we want to handle in a generic manner.
@Provider
public class BusinessExceptionMapper implements ExceptionMapper<BusinessException> {

    private static final Logger log = Logger.getLogger(TestEndpoint.class.getName());
    
    private static final ResourceBundle resource = 
        ResourceBundle.getBundle("com.blogspot.avianey");

    @Override
    public Response toResponse(BusinessException e) {
        log.log(Level.FINER, "Business problem while executing testMethod", e);
        return Response.status(Status.BAD_REQUEST)
            .type(MediaType.TEXT_PLAIN)
            .entity(resource.getString(e.getMessage()))
            .build();
    }

}
@Provider
public class TechnicalExceptionMapper implements ExceptionMapper<TechnicalException> {

    private static final Logger log = Logger.getLogger(TestEndpoint.class.getName());
    
    private static final ResourceBundle resource = 
        ResourceBundle.getBundle("com.blogspot.avianey");

    @Override
    public Response toResponse(TechnicalException e) {
        log.log(Level.FINER, "Technical problem while executing testMethod", e);
        return Response.status(Status.INTERNAL_SERVER_ERROR)
            .type(MediaType.TEXT_PLAIN)
            .entity(resource.getString("error.internal"))
            .build();
    }

}
Just as service classes, @Provider classes should be placed in a package that is scanned by the Jersey Servlet at startup so they can be used at runtime.
<servlet>
    <servlet-name>Jersey</servlet-name>
    <servlet-class>com.sun.jersey.spi.container.servlet.ServletContainer</servlet-class>
    <init-param>
        <param-name>com.sun.jersey.config.property.packages</param-name>
        <param-value>pkg.provider; pkg.endpoint</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
</servlet>
We no longer have to handle explicitly BusinessException and TechnicalException in our exposed method :
@Path("/test")
@Produces("application/json")
public class TestEndpoint {
    
    private static final Logger log = Logger.getLogger(TestEndpoint.class.getName());
    
    @Inject TestService service;
    
    @POST
    @Path("{pathParam: \\d+}")
    public String testMethod(
            @PathParam("pathParam") Long id,
            @FormParam("date") String dateStr,
            @Context HttpServletRequest request) {

        // verifying that the user is logged in
        User user = (User) request.getSession().getAttribute("user");
        if (user == null) {
            throw new WebApplicationException(Status.UNAUTHORIZED);
        }
        
        // verifying the format of the sent date
        Date date = null;
        SimpleDateFormat sdf = new SimpleDateFormat("dd/MM/yyyy");
        try {
            date = sdf.parse(dateStr);
        } catch (ParseException pe) {
            throw new WebApplicationException(Status.BAD_REQUEST);
        }
        
        // calling the business logic
        // no need to catch exception here anymore
        return service.testOperation(id, date, user);
        
    }
    
}

Custom Jersey parameters

JAX-RS Param annotations like QueryParam, FormParam and PathParam can be apply to any Java Object that have a constructor with a single String argument. When calling a method with such an annotated parameter, Jersey instantiate a new object with the param value and pass it to the method.

We can easily use this feature to centralize the validation of date parameters accross all of our exposed methods :
/**
 * A DateParam to validate the format of date parameters received by Jersey
 */
public class DateParam {

    private Date date;
    
    public DateParam(String dateStr) {
        SimpleDateFormat sdf = new SimpleDateFormat("dd/MM/yyyy");
        try {
            this.date = sdf.parse(dateStr);
        } catch (ParseException pe) {
            throw new WebApplicationException(Status.BAD_REQUEST);
        }
    }
    
    public Date value() {
        return this.date;
    }
    
}
Once again, our original code gained in maintainability :
@Path("/test")
@Produces("application/json")
public class TestEndpoint {
    
    private static final Logger log = Logger.getLogger(TestEndpoint.class.getName());
    
    @Inject TestService service;
    
    @POST
    @Path("{pathParam: \\d+}")
    public String testMethod(
            @PathParam("pathParam") Long id,
            @FormParam("date") DateParam date,
            @Context HttpServletRequest request) {

        // verifying that the user is logged in
        User user = (User) request.getSession().getAttribute("user");
        if (user == null) {
            throw new WebApplicationException(Status.UNAUTHORIZED);
        }
        
        // calling the business logic
        return service.testOperation(id, date.value(), user);
        
    }
    
}

Contextual object injection

Now we will see how it possible to directly inject the logged User into our exposed method. There is two diferent approaches doing this :
  1. Use the @Context annotation with a custom provider to inject the desired Object
  2. Create a custom annotation and its Injectable and associated InjectableProvider
In the first approach, we define a new Injectable implementation for the User Type and associate it with the @Context annotation.
@Provider
public class LoggedUserProvider extends AbstractHttpContextInjectable<User>
        implements InjectableProvider<Context, Type> {

    private final HttpServletRequest r;
    
    public LoggedUserProvider(@Context HttpServletRequest r) {
        this.r = r;
    }

    /**
     * From interface InjectableProvider
     */
    @Override
    public Injectable<user> getInjectable(ComponentContext ic, Context a, Type c) {
        if (c.equals(User.class)) {
            return this;
        }
        return null;
    }

    /**
     * From interface InjectableProvider
     * A new Injectable is instanciated per request
     */
    @Override
    public ComponentScope getScope() {
        return ComponentScope.PerRequest;
    }

    /**
     * From interface Injectable
     * Get the logged User associated with the request
     * Or throw an Unauthorized HTTP status code
     */
    @Override
    public User getValue(HttpContext c) {
        final User user = Contestr.getSessionUser(r);
        if (user == null) {
            throw new WebApplicationException(Response.Status.UNAUTHORIZED);
        }
        return user;
    }
}
The exposed method consist now of one line of code only !
@Path("/test")
@Produces("application/json")
public class TestEndpoint {
    
    @Inject TestService service;
    
    @POST
    @Path("{pathParam: \\d+}")
    public String testMethod(
            @PathParam("pathParam") Long id,
            @FormParam("date") DateParam date,
            @Context User user) {
        // calling the business logic
        return service.testOperation(id, date.value(), user);
    }
    
}
In the second approach, we need to define a new annotation :
@Target({ ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface LoggedUser {}
Just as seen before, we need to associate the @LoggedUser with a new Injectable for the User type :
@Provider
public class LoggedUserProvider  
    implements Injectable<User>, InjectableProvider<LoggedUser, Type> {

    private final HttpServletRequest r;
    
    public LoggedUserProvider(@Context HttpServletRequest r) {
        this.r = r;
    }

    @Override
    public Injectable<user> getInjectable(ComponentContext cc, LoggedUser a, Type c) {
        if (c.equals(User.class)) {
            return this;
        }
        return null;
    }

    /**
     * From interface InjectableProvider
     * A new Injectable is instanciated per request
     */
    @Override
    public ComponentScope getScope() {
        return ComponentScope.PerRequest;
    }

    /**
     * From interface Injectable
     * Get the logged User associated with the request
     * Or throw an Unauthorized HTTP status code
     */
    @Override
    public User getValue() {
        final User user = (User) r.getSession().getAttribute("user");
        if (user == null) {
            throw new WebApplicationException(Response.Status.UNAUTHORIZED);
        }
        return user;
    }
    
}
This solution is much more flexible than the previous one as we can build more than one annotation for the same Type :
  • @LoggedUser : retrieve the logged in User and throw an HTTP 401 unauthorized status code if no logged in User is associated with the current request.
  • @AdminLoggedUser : retrieve the logged in User and throw an HTTP 401 unauthorized status code if no logged in User is associated with the current request or if the User is not an administrator.
@Path("/test")
@Produces("application/json")
public class TestEndpoint {
    
    @Inject TestService service;
    
    @POST
    @Path("{pathParam: \\d+}")
    public String testMethod(
            @PathParam("pathParam") Long id,
            @FormParam("date") DateParam date,
            @LoggedUser User user) {
        // calling the business logic
        return service.testOperation(id, date.value(), user);
    }
    
}
In a next post, I will cover the integration of Jersey with Guice or any other JSR-330 compliant IOC framework.

dimanche 20 mars 2011

Tests unitaires d'un application Jersey JAX-RS + JPA

Nous allons aborder dans ce billet la problématique des tests unitaires pour une application WEB JPA en générale, et une application WEB JAX-RS utilisant Jersey en particulier. L'application WEB à tester est une application Maven et les tests seront intégrés à la phase de tests du cycle de vie Maven.

L'objectif est de parvenir à automatiser le déploiement de l'application en s'appuyant sur une base HSQLDB in memory et un fichier de configuration persistence.xml spécifiques à la phase de tests. Il sera alors possible de construire une campagne de tests unitaires via l'utilisation de la librairie Jersey Client API.

Application à tester

Nous utiliserons Hibernate 3.6, MySQL et Jersey 1.4 :

    
        org.hibernate
        hibernate-entitymanager
        3.6.0.Final
    
    
        mysql
        mysql-connector-java
        5.1.6
    
    
        com.sun.jersey
        jersey-bundle
        1.4
    
    
        javax.servlet
        servlet-api
        2.5
        provided
    
L'application à tester consiste en un datastore REST permettant de stocker des couples (clé, valeur) en s'appuyant sur l'entitée JPA suivante :
package com.googlecode.avianey.jersey;

import javax.persistence.Entity;
import javax.persistence.Id;

/**
 * Simple bean for storing (key, value) pairs
 */
@Entity
public class JerseyJPABean {

    @Id
    private String key;
    private String value;
    
    public String getKey() {
        return key;
    }
    public void setKey(String key) {
        this.key = key;
    }
    public String getValue() {
        return value;
    }
    public void setValue(String value) {
        this.value = value;
    }

}
La persistance des données est réalisée par une base de données relationnelle MySQL. Le fichier persistence.xml de paramétrage JPA de "production" est le suivant :
<persistence
    version="2.0"
    xmlns="http://java.sun.com/xml/ns/persistence"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="
        http://java.sun.com/xml/ns/persistence
        http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">

    <persistence-unit name="pu" transaction-type="RESOURCE_LOCAL">
        <provider>org.hibernate.ejb.HibernatePersistence</provider>
        <class>com.googlecode.avianey.jersey.JerseyJPABean</class>
        <exclude-unlisted-classes>true</exclude-unlisted-classes>
        <properties>
            <property name="hibernate.connection.driver_class" value="com.mysql.jdbc.Driver" />
            <property name="hibernate.dialect" value="org.hibernate.dialect.MySQLDialect" />
            <property name="hibernate.connection.url" value="jdbc:mysql://localhost:3306/prod" />
            <property name="hibernate.connection.username" value="mysql" />
            <property name="hibernate.connection.password" value="mysql" />
            <property name="hibernate.hbm2ddl.auto" value="update" />
            <property name="hibernate.show_sql" value="false" />
        </properties>
    </persistence-unit>
    
</persistence>
Les services REST sont exposés par Jersey à l'adresse "/" :
<?xml version="1.0" encoding="UTF-8"?>
<web-app 
    version="2.5" 
    xmlns="http://java.sun.com/xml/ns/javaee" 
    xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" 
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee       
                        http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">

    <!-- SERVICES REST -->
    <servlet>
        <servlet-name>Jersey</servlet-name>
        <servlet-class>
            com.sun.jersey.spi.container.servlet.ServletContainer
        </servlet-class>
        <init-param>
            <param-name>com.sun.jersey.config.property.packages</param-name>
            <param-value>com.googlecode.avianey.jersey</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>Jersey</servlet-name>
        <url-pattern>/*</url-pattern>
    </servlet-mapping>

    <session-config>
        <session-timeout>30</session-timeout>
    </session-config>

</web-app>
L'implémentation des services est un CRUD permettant de :
Stocker une valeur (PUT) :
Stocke une valeur à l'adresse correspondant à l'URL de la méthode HTTP PUT utilisée
Mettre à jour une valeur (POST) :
Met à jour la valeur stockée à l'adresse correspondant à l'URL de la méthode HTTP POST utilisée
Récupérer une valeur (GET) :
Récupère la valeur stockée à l'adresse correspondant à l'URL de la méthode HTTP GET utilisée
Supprimer une valeur (DELETE) :
Supprime la valeur à l'adresse correspondant à l'URL de la méthode HTTP DELETE utilisée
Les codes retours HTTP suivants sont utilisées afin de gérer les différentes erreurs et conflits rencontrés lors du traitement d'une requête :
200
Le traitement de la requête s'est correctement déroulé
400
La requête est refusée par le serveur : tentative de stockage d'une valeur indéfinie.
404
La donnée n'existe pas : pas de valeur stockée pour la clé demandée.
409
Conflit de données : création d'une valeur pour une clé déjà existante.
500
Erreur technique du serveur.
package com.googlecode.avianey.jersey;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityNotFoundException;
import javax.persistence.Persistence;
import javax.persistence.PersistenceException;
import javax.persistence.Query;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;

@Path("/")
@Produces("application/json")
@Consumes("application/json")
public class JerseyJPAService {

    private static final EntityManagerFactory EMF =
            Persistence.createEntityManagerFactory("pu");

    /**
     * Add a new element
     * @param value
     */
    @PUT
    @Path("/{key: \\w+}")
    public void put(@PathParam("key") String key, String value) {
        if (value == null) {
            // HTTP 400 : Bad Request
            throw new WebApplicationException(400);
        }
        JerseyJPABean bean = new JerseyJPABean();
        bean.setKey(key);
        bean.setValue(value);
        EntityManager em = EMF.createEntityManager();
        try {
            em.getTransaction().begin();
            em.persist(bean);
            em.getTransaction().commit();
        } catch (PersistenceException e) {
            if (get(key) != null) {
                // HTTP 409 : Conflict
                throw new WebApplicationException(409);
            } else {
                // HTTP 500 : Internal Server Error
                throw new WebApplicationException(500);
            }
        } finally {
            if (em.getTransaction().isActive()) {
                try {
                    em.getTransaction().rollback();
                } catch (Exception e) {}
            }
            em.close();
        }
    }

    /**
     * Update an existing element
     * @param bean
     */
    @POST
    @Path("/{key: \\w+}")
    public void update(@PathParam("key") String key, String value) {
        if (value == null || value.trim().length() == 0) {
            // Delete existing stored value
            delete(key);
        } else {
            EntityManager em = EMF.createEntityManager();
            try {
                em.getTransaction().begin();
                JerseyJPABean bean = (JerseyJPABean) em.getReference(JerseyJPABean.class, key);
                bean.setValue(value);
                em.merge(bean);
                em.getTransaction().commit();
            } catch (EntityNotFoundException e) {
                // HTTP 404 : Not Found
                throw new WebApplicationException(404);
            } catch (PersistenceException e) {
                // HTTP 500 : Internal Server Error
                throw new WebApplicationException(500);
            } finally {
                if (em.getTransaction().isActive()) {
                    try {
                        em.getTransaction().rollback();
                    } catch (Exception e) {}
                }
                em.close();
            }
        }
    }
    
    /**
     * Retrieve a persisted element
     * @param key
     * @return
     */
    @GET
    @Path("/{key: \\w+}")
    public String get(@PathParam("key") String key) {
        EntityManager em = EMF.createEntityManager();
        try {
            JerseyJPABean bean = (JerseyJPABean) em.getReference(JerseyJPABean.class, key);
            return bean.getValue();
        } catch (EntityNotFoundException e) {
            // HTTP 404 : Not Found
            throw new WebApplicationException(404);
        } finally {
            em.close();
        }
    }
    
    /**
     * Delete a persisted element
     * @param key
     */
    @DELETE
    @Path("/{key: \\w+}")
    public void delete(@PathParam("key") String key) {
        EntityManager em = EMF.createEntityManager();
        try {
            em.getTransaction().begin();
            Query q = em.createQuery("DELETE JerseyJPABean WHERE key = :key");
            q.setParameter("key", key);
            if (q.executeUpdate() == 0) {
                // HTTP 404 : Not Found
                throw new WebApplicationException(404);
            }
            em.getTransaction().commit();
        } catch (Exception e) {
            // HTTP 500 : Internal Server Error
            throw new WebApplicationException(500);
        } finally {
            if (em.getTransaction().isActive()) {
                try {
                    em.getTransaction().rollback();
                } catch (Exception e) {}
            }
            em.close();
        }
    }

}

Configuration des tests

L'une des problématique principale concernant les tests unitaires d'une application WEB persistant ses données en base est de pouvoir disposer d'une base de données réservée aux tests qui permette d'accueillir des jeux de test divers et variés. Dans un contexte JPA, il convient de créer un fichier de configuration persistence.xml spécifique, stocké dans le répertoire src/test/resources du projet, et utilisant une base HSQLDB in memory :
<persistence
    version="2.0"
    xmlns="http://java.sun.com/xml/ns/persistence"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="
        http://java.sun.com/xml/ns/persistence
        http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">

    <persistence-unit name="pu" transaction-type="RESOURCE_LOCAL">
        <provider>org.hibernate.ejb.HibernatePersistence</provider>
        <class>com.googlecode.avianey.jersey.JerseyJPABean</class>
        <exclude-unlisted-classes>true</exclude-unlisted-classes>
        <properties>
            <property name="hibernate.connection.driver_class" value="org.hsqldb.jdbcDriver" />
            <property name="hibernate.dialect" value="org.hibernate.dialect.HSQLDialect" />
            <property name="hibernate.connection.url" value="jdbc:hsqldb:mem:test" />
            <property name="hibernate.connection.username" value="sa" />
            <property name="hibernate.connection.password" value="" />
            <property name="hibernate.hbm2ddl.auto" value="update" />
            <property name="hibernate.show_sql" value="true" />
        </properties>
    </persistence-unit>
    
</persistence>
L'arborescence du projet Maven est la suivante :


Arborescence du projet Maven de test

Les librairies supplémentaires à ajouter au CLASSPATH de test contiennent notamment :
  • JUnit : pour monter les jeux de test et définir pour chaque test le résultat attendu
  • HSQLDB : pour disposer d'une base de données de test in memory
  • Jetty : pour déployer les sources à tester dans un serveur embarqué

    
        org.hibernate
        hibernate-entitymanager
        3.6.0.Final
    
    
        mysql
        mysql-connector-java
        5.1.6
    
    
        com.sun.jersey
        jersey-bundle
        1.4
    
    
        javax.servlet
        servlet-api
        2.5
        provided
    
    
        org.hsqldb
        hsqldb
        2.0.0
        test
    
    
        junit
        junit
        4.8.2
        jar
        test
    
    
        com.sun.jersey.jersey-test-framework
        jersey-test-framework-core
        1.4
        jar
        test
    
    
        org.mortbay.jetty
        jetty
        6.1.17
        jar
        test
        
            
                servlet-api
                org.mortbay.jetty
            
        
    
Lors de l'exécution de la phase de test par Maven, le CLASSPATH utilisé contient les librairies applicatives ainsi que les classes JAVA du répertoire src/main auquelles sont ajoutées les librairies de test (celles dont les scope est <test>) et les classes JAVA du répertoire src/test. Le CLASSPATH contient donc deux fichiers différents nommés tous deux META-INF/persistence.xml :
src/main/resources/META-INF/persistence.xml
Fichier de configuration JPA utilisé pour le packaging final
src/main/resources/META-INF/persistence.xml
Fichier de configuration JPA utilisé pour les tests unitaires
Pour s'assurer de l'utilisation du fichier de configuration de test lors de l'exécution de la phase de tests par Maven, il faut utiliser un ClassLoader personnalisé dont l'effet est de ne mettre en visibilité que le fichier META-INF/persistence.xml du répertoire src/test/resources :
public static class ClassLoaderProxy extends ClassLoader {

    public ClassLoaderProxy(final ClassLoader parent) {
        super(parent);
    }

    @Override
    public Enumeration<url> getResources(final String name) throws IOException {
        if (!"META-INF/persistence.xml".equals(name)) {
            return super.getResources(name);
        } else {
            System.out.println("Redirecting to specific persistence.xml");
            return new Enumeration<url>() {

                private URL persistenceUrl = (new File(
                        "src/test/resources/META-INF/persistence.xml"))
                        .toURI().toURL();

                @Override
                public boolean hasMoreElements() {
                    return persistenceUrl != null;
                }

                @Override
                public URL nextElement() {
                    final URL url = persistenceUrl;
                    System.out.println("Using custom persistence.xml : " + 
                            url.toString());
                    persistenceUrl = null;
                    return url;
                }

            };
        }
    }
}
Lors de la mise en place des tests dans la méthode setUp() du TestCase JUnit, la base HSQLDB in memory est démarrée à l'adresse indiquée dans le fichier persistence.xml de test. Une base vierge de toute donnée est ainsi disponible pour y déployer des jeux de tests et données spécifiques. Un serveur Jetty embedded est quant à lui lancé sur le port 8888 et configuré pour exposer notre application WEB sur le context "/" en utilisant le ClassLoader personnalisé définit précédemment :
@Override
protected void setUp() throws Exception {
    super.setUp();
    // Start HSQLDB
    try {
        Class.forName("org.hsqldb.jdbcDriver");
        con = DriverManager.getConnection(
                "jdbc:hsqldb:mem:test",
                "sa", "");
    } catch (Exception e) {
        e.printStackTrace();
        fail("Failed to start HSQLDB");
    }

    // Start Jetty
    try {
        System.setProperty("org.mortbay.log.class",
                "com.citizenmind.service.rest.TestRestService.CustomLogger");
        server = new Server(8888);
        WebAppContext context = new WebAppContext();
        context.setResourceBase("src/main/webapp");
        context.setContextPath("/");
        Thread.currentThread().setContextClassLoader(
                new ClassLoaderProxy(
                Thread.currentThread().getContextClassLoader()));
        context.setClassLoader(Thread.currentThread().getContextClassLoader());
        context.setParentLoaderPriority(true);
        server.setHandler(context);
        server.start();
    } catch (Exception e) {
        e.printStackTrace();
        server.stop();
        fail("Failed to start embedded Jetty");
    }
}
Lors de l'arrêt des tests dans la méthode tearDown() du TestCase JUnit, la base de données HSQLDB ainsi que le serveur Jetty embedded sont arrêtés :
@Override
protected void tearDown() throws Exception {
    super.tearDown();
    // Stop HSQLDB
    try {
        con.createStatement().execute("SHUTDOWN");
    } catch (Exception e) {
        e.printStackTrace();
    }
    // Stop jetty
    try {
        server.stop();
    } catch (Exception e) {
        e.printStackTrace();
        System.err.println("Jetty must be killed manually");
    }
}
La librairie Jersey Client API est utilisée pour dérouler les tests en interrogeant les services REST exposés par le serveur Jetty embedded :
/**
 * Test REST Services using Jersey client API
 */
public void testJerseyJPAService() {

    // Create a Jersey client
    Client c = Client.create();
    WebResource r;
    
    // Insert two values :
    // - (A, 1)
    // - (B, 2)
    try {
        r = c.resource(URL + "/A");
        r.type(MediaType.APPLICATION_JSON_TYPE)
            .accept(MediaType.APPLICATION_JSON_TYPE)
            .put("1");
        r = c.resource(URL + "/B");
        r.type(MediaType.APPLICATION_JSON_TYPE)
            .accept(MediaType.APPLICATION_JSON_TYPE)
            .put("2");
    } catch (UniformInterfaceException e) {
        e.printStackTrace();
        fail("Could not insert values");
    }

    // Store a value with an existing key
    try {
        r = c.resource(URL + "/B");
        r.type(MediaType.APPLICATION_JSON_TYPE)
            .accept(MediaType.APPLICATION_JSON_TYPE)
            .put("2");
    } catch (UniformInterfaceException e) {
        // HTTP 409 : Conflict
        assertTrue(e.getResponse()
                .getClientResponseStatus()
                .getStatusCode() == 409);
    }
    
    // Verify values
    r = c.resource(URL + "/A");
    String A = r.type(MediaType.APPLICATION_JSON_TYPE)
        .accept(MediaType.APPLICATION_JSON_TYPE)
        .get(String.class);
    assertTrue("1".equals(A));
    r = c.resource(URL + "/B");
    String B = r.type(MediaType.APPLICATION_JSON_TYPE)
        .accept(MediaType.APPLICATION_JSON_TYPE)
        .get(String.class);
    assertTrue("2".equals(B));
    
    // Update B
    r = c.resource(URL + "/B");
    r.type(MediaType.APPLICATION_JSON_TYPE)
        .accept(MediaType.APPLICATION_JSON_TYPE)
        .post("3");
    B = r.type(MediaType.APPLICATION_JSON_TYPE)
        .accept(MediaType.APPLICATION_JSON_TYPE)
        .get(String.class);
    assertTrue("3".equals(B));
    
    // Update C
    try {
        r = c.resource(URL + "/C");
        r.type(MediaType.APPLICATION_JSON_TYPE)
            .accept(MediaType.APPLICATION_JSON_TYPE)
            .post("3");
    } catch (UniformInterfaceException e) {
        // HTTP 404 : Resource C does not exists
        assertTrue(e.getResponse()
                .getClientResponseStatus()
                .getStatusCode() == 404);
    }
    
    // Delete A
    r = c.resource(URL + "/A");
    r.type(MediaType.APPLICATION_JSON_TYPE)
        .accept(MediaType.APPLICATION_JSON_TYPE)
        .delete();
    try {
        Statement st = con.createStatement();
        ResultSet rs = st.executeQuery(
                "Select * From JerseyJPABean Where key = 'A'");
        assertTrue(!rs.next());
        rs.close();
        st.close();
    } catch (SQLException e) {
        e.printStackTrace();
        fail("Could not execute SQL Select");
    }
    
    // Get A
    try {
        A = r.type(MediaType.APPLICATION_JSON_TYPE)
            .accept(MediaType.APPLICATION_JSON_TYPE)
            .get(String.class);
    } catch (UniformInterfaceException e) {
        // HTTP 404 : Resource A does not exists enymore
        assertTrue(e.getResponse()
                .getClientResponseStatus()
                .getStatusCode() == 404);
    }
}

Résultat final

Le projet complet Maven utilisé pour illustrer ce billet est disponible sous licence Apache License 2.0 à cette adresse :

Un petit test :

mvn test

Et l'on remarque le déploiement des services sous Jetty, la redirection du ClassLoader personnalisé ainsi que le détail des requêtes Hibernate :

-------------------------------------------------------
T E S T S
-------------------------------------------------------
Running com.googlecode.avianey.jersey.JerseyJPATest
2011-03-20 17:10:18.427::INFO: Logging to STDERR via org.mortbay.log.StdErrLog
2011-03-20 17:10:18.463::INFO: jetty-6.1.17
2011-03-20 17:10:18.531::INFO: NO JSP Support for /, did not find org.apache.jasper.servlet.JspServlet
20 mars 2011 17:10:18 com.sun.jersey.api.core.PackagesResourceConfig init
INFO: Scanning for root resource and provider classes in the packages:
com.googlecode.avianey.jersey
20 mars 2011 17:10:18 com.sun.jersey.api.core.ScanningResourceConfig logClasses
INFO: Root resource classes found:
class com.googlecode.avianey.jersey.JerseyJPAService
20 mars 2011 17:10:18 com.sun.jersey.api.core.ScanningResourceConfig init
INFO: No provider classes found.
20 mars 2011 17:10:18 com.sun.jersey.server.impl.application.WebApplicationImpl _initiate
INFO: Initiating Jersey application, version 'Jersey: 1.4 09/11/2010 10:41 PM'
2011-03-20 17:10:19.323::INFO: Started SocketConnector@0.0.0.0:8888
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
Redirecting to specific persistence.xml
Using custom persistence.xml : file:/C:/projets/jersey-jpa-test/src/test/resources/META-INF/persistence.xml

Hibernate: insert into JerseyJPABean (value, key) values (?, ?)
Hibernate: insert into JerseyJPABean (value, key) values (?, ?)
Hibernate: insert into JerseyJPABean (value, key) values (?, ?)
Hibernate: select jerseyjpab0_.key as key0_0_, jerseyjpab0_.value as value0_0_ from JerseyJPABean jerseyjpab0_ where jerseyjpab0_.key=?
Hibernate: select jerseyjpab0_.key as key0_0_, jerseyjpab0_.value as value0_0_ from JerseyJPABean jerseyjpab0_ where jerseyjpab0_.key=?
Hibernate: select jerseyjpab0_.key as key0_0_, jerseyjpab0_.value as value0_0_ from JerseyJPABean jerseyjpab0_ where jerseyjpab0_.key=?
Hibernate: select jerseyjpab0_.key as key0_0_, jerseyjpab0_.value as value0_0_ from JerseyJPABean jerseyjpab0_ where jerseyjpab0_.key=?
Hibernate: update JerseyJPABean set value=? where key=?
Hibernate: select jerseyjpab0_.key as key0_0_, jerseyjpab0_.value as value0_0_ from JerseyJPABean jerseyjpab0_ where jerseyjpab0_.key=?
Hibernate: select jerseyjpab0_.key as key0_0_, jerseyjpab0_.value as value0_0_ from JerseyJPABean jerseyjpab0_ where jerseyjpab0_.key=?
Hibernate: delete from JerseyJPABean where key=?
Hibernate: select jerseyjpab0_.key as key0_0_, jerseyjpab0_.value as value0_0_ from JerseyJPABean jerseyjpab0_ where jerseyjpab0_.key=?
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.343 sec

mardi 12 octobre 2010

Android JAX-RS Client : partie serveur avec Jersey et App Engine

En 2008, Google a ouvert son offre de cloud computing au monde Java. Depuis, le Google appEngine permet à n'importe quelle personne de déployer une application web Java scalable et à haute disponibilité ! Dans ce tutorial, nous allons voir comment exposer un service REST en JSON dans le cloud au moyen de l'appEngine et de l'api Jersey JAX-RS. Le webservice exposé consistera en un simple CRUD d'objets de type User.

Téléchargement et installation des outils

Pour suivre ce tutorial, vous aurez besoin des outils et api suivantes :
Une fois téléchargées et installée, créez un nouveau Web Application Project :


Ajoutez les librairies Jersey suivante dans le répertoire war/WEB-INF :
  • asm.jar
  • commons-validator.jar
  • jackson-core-asl.jar
  • jackson-jaxrs.jar
  • jackson-mapper-asl.jar
  • jackson-xc.jar
  • jersey-client.jar
  • jersey-core.jar
  • jersey-json.jar
  • jettison.jar
  • jsr311-api.jar


Mise en place du modèle de données

Dans le répertoire src/META-INF du projet, nous allons remplacer le fichier de configuration JDO créé par défaut par un fichier de configuration JPA persistence.xml :

<?xml version="1.0" encoding="UTF-8" ?>
<persistence version="1.0"
    xmlns="http://java.sun.com/xml/ns/persistence"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/persistence
                        http://java.sun.com/xml/ns/persistence/persistence_1_0.xsd">

    <persistence-unit name="transactions-optional">
        <provider>
                org.datanucleus.store.appengine.jpa.DatastorePersistenceProvider
        </provider>
        <properties>
            <property name="datanucleus.NontransactionalRead" value="true"/>
            <property name="datanucleus.NontransactionalWrite" value="true"/>
            <property name="datanucleus.ConnectionURL" value="appengine"/>
        </properties>
    </persistence-unit>

</persistence>

Nous nous contenterons de la configuration JPA minimale.
Notre entité persisté sera une classe User :

package net.androgames.blog.sample.rest.server;

import java.io.Serializable;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

import org.datanucleus.jpa.annotations.Extension;


@Entity
public class User implements Serializable {

    /**
     * 1 : Version initiale
     */
    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Extension(vendorName="datanucleus", key="gae.encoded-pk", value="true")
    private String id;
    
    private String nom;
    private String prenom;
    
    public String getId() {
        return id;
    }
    public void setId(String id) {
        this.id = id;
    }
    public String getNom() {
        return nom;
    }
    public void setNom(String nom) {
        this.nom = nom;
    }
    public String getPrenom() {
        return prenom;
    }
    public void setPrenom(String prenom) {
        this.prenom = prenom;
    }
    
}

Mise en place de Jersey et CRUD simple

Pour exposer les fonctionnalités de création, suppression, modification et récupération en REST, nous allons utiliser l'api Jersey. Comme la plupart des framework utilisés dans une application web, sa configuration s'effectue en déclarant une Servlet spécifique dans le fichier web.xml :

<?xml version="1.0" encoding="utf-8"?>
<web-app 
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns="http://java.sun.com/xml/ns/javaee"
    xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
                        http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" 
    version="2.5">

    <servlet>
        <servlet-name>Jersey</servlet-name>
        <servlet-class>
            com.sun.jersey.spi.container.servlet.ServletContainer
        </servlet-class>
        <!-- Packages a analyser -->
        <init-param>
            <param-name>com.sun.jersey.config.property.packages</param-name>
            <param-value>net.androgames.blog.sample.rest.server</param-value>
        </init-param>
        <!-- Mapping JSON POJO -->
        <init-param>
            <param-name>com.sun.jersey.api.json.POJOMappingFeature</param-name>
            <param-value>true</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    
    <servlet-mapping>
        <servlet-name>Jersey</servlet-name>
        <url-pattern>/*</url-pattern>
    </servlet-mapping>
    
    <session-config>
        <session-timeout>30</session-timeout>
    </session-config>
    
</web-app>

La sérialisation / désérialisation des POJO par Jersey sera effectuée par la classe com.sun.jersey.api.json.POJOMappingFeature. Jersey va ainsi supporter la sérialisation / désérialisation au format JSON. L'implémentation de notre CRUD est la suivante :

package net.androgames.blog.sample.rest.server;

import java.util.List;
import java.util.logging.Logger;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;

import com.sun.jersey.api.NotFoundException;

@Path("/user")
@Produces("application/json")
@Consumes("application/json")
public class UserService {
    
    private static final Logger log = Logger.getLogger(UserService.class.getName());
    
    private static final EntityManagerFactory ENTITY_MANAGER = 
        Persistence.createEntityManagerFactory("transactions-optional");
    
    public static EntityManager getEntityManager() {
        return ENTITY_MANAGER.createEntityManager();
    }

    /**
     * Mise a jour d'un utilisateur par son id
     * @param id
     * @param user
     * @return
     */
    @POST
    @Path("{id}")
    public User update(
            @PathParam("id") String id, 
            User user) {
        log.info("Mise a jour du user d'id : " + id);
        
        if (user == null) {
            throw new IllegalArgumentException();
        }
        
        EntityManager em = getEntityManager();
        User persistedUser = em.getReference(User.class, id);
        
        if (persistedUser == null) {
            throw new NotFoundException();
        }
        
        persistedUser.setNom(user.getNom());
        persistedUser.setPrenom(user.getPrenom());

        em.getTransaction().begin();
        em.merge(persistedUser);
        em.getTransaction().commit();
        
        return persistedUser;
    }

    /**
     * Recupere un utilisateur par son id
     * @param deviceId
     * @return
     */
    @GET
    @Path("{id}")
    public User get(@PathParam("id") String id) {
        log.info("Recuperation du user d'id : " + id);
        
        EntityManager em = getEntityManager();
        User persistedUser = em.getReference(User.class, id);
        
        if (persistedUser == null) {
            throw new NotFoundException();
        }
        
        return persistedUser;
    }

    /**
     * Recuperation de la liste des utilisateurs
     * @param deviceId
     * @return
     */
    @GET
    @SuppressWarnings("unchecked")
    public List<User> list() {
        log.info("Recuperation des utilisateurs");
        
        EntityManager em = getEntityManager();
        List<User> users = em.createQuery("SELECT u FROM User u").getResultList();
        
        return users;
    }

    /**
     * Supprime un utilisateur par son id
     * @param deviceId
     * @return
     */
    @DELETE
    @Path("{id}")
    public String delete(@PathParam("id") String id) {
        log.info("Suppression du user d'id : " + id);
        
        EntityManager em = getEntityManager();
        User persistedUser = em.getReference(User.class, id);
        
        if (persistedUser == null) {
            throw new NotFoundException();
        }

        em.getTransaction().begin();
        em.remove(persistedUser);
        em.getTransaction().commit();
        
        return id;
    }

    /**
     * Ajoute un utilisateur
     * @param deviceId
     * @return
     */
    @PUT
    public User add(User user) {
        log.info("Ajout d'un utilisateur");
        
        EntityManager em = getEntityManager();
        em.getTransaction().begin();
        em.persist(user);
        em.getTransaction().commit();
        
        return user;
    }
    
}

Jersey se configure au moyen d'annotations :
@Path
Cette annotation permet de spécifier le chemin d'accès à la ressource relativement au chemin paramétré dans le fichier web.xml pour la Servlet Jersey. Utilisée sur la déclaration d'une classe, elle s'applique à toutes ses méthodes. Utilisée sur une méthode, elle s'applique relativement au Path définit pour la classe.
@PathParam
Elle permet de récupérer une partie du Path en paramètre d'une méthode. Dans notre exemple, nous déclarons le Path "/user/{id}" pour la méthode get. Ainsi définit, notre Path nous permet de récupérer la partie mappée par "{id}" grâce à l'annotation PathParam.
@PUT, @POST, @GET, @DELETE
Ces annotations permettent de spécifier la méthode HTTP à mapper sur une méthode de la classe UserService. Seules les requêtes du type spécifié seront transmises à la méthode. Cela permet d'utiliser le même chemin "/user" pour la création d'un utilisateur (PUT), la récupération de tous les utilisateurs (GET) et la suppression d'un utilisateur (DELETE). La récupération d'un utilisateur (GET) et la imse à jour d'un utilisateur (POST) partagent le chemin "/user/{id}".
@Produce
Cette annotation permet de préciser le (ou les) type MIME que Jersey peut utiliser pour sérialiser les retours des méthodes annotées PUT, GET, POST, DELETE, ... Dans notre exemple, nous avons spécifier une sérialisation en JSON pour l'ensemble des méthodes de la classe. Il est également possible de spécifier le type MIME par version, ou d'en spécifier plusieurs séparés par des virgules. Dans ce cas, le type MIME utilisé pour la sérialisation correspond au premier type MIME rencontré dans le header HTTP Accept.
@Consume
Tout comme l'annotation Produce, cette annotation permet de définir un type MIME et, plus précisément, le type MIME qui sera utiliser par Jersey pour désérialiser les valeurs à passer en paramètre aux méthodes exposées. Dans notre exemple, nous attendons donc pour les méthodes add et update une requête HTTP nous envoyant un bean User sérialisé en JSON. Il est également possible de spécifier le type MIME par méthode ou d'en préciser plus d'un.

Test des services avec l'API de test Jersey


Jersey offre la possibilité de recetter rapidement un webservice REST en utilisant la classe Client de son API. L'exemple ci dessous montre comment tester rapidement l'ajout, la mise à jour, la récupération et la suppression d'un utilisateur.

package net.androgames.blog.sample.rest.server;

import javax.ws.rs.core.MediaType;

import com.sun.jersey.api.client.Client;
import com.sun.jersey.api.client.WebResource;

public class TestUserService {

    private static final String URL = "http://localhost:8888/user";
    
    /**
     * 
     * @param args
     */
    public static void main(String[] args) {
        
        // creation d'un client Jersey
        Client c = Client.create();
        WebResource r;
        User user;
        
        // test d'insertion d'un utilisateur
        r = c.resource(URL);
        user = new User();
        user.setNom("Vianey");
        user.setPrenom("");
        user = r.type(MediaType.APPLICATION_JSON_TYPE)
                .accept(MediaType.APPLICATION_JSON_TYPE)
                .put(User.class, user);
        
        System.out.println("User enregistré avec l'id : " + user.getId());
        
        // test de mise a jour de l'utilisateur
        r = c.resource(URL + "/" + user.getId());
        user = new User();
        user.setNom("Vianey");
        user.setPrenom("Antoine");
        user = r.type(MediaType.APPLICATION_JSON_TYPE)
                .accept(MediaType.APPLICATION_JSON_TYPE)
                .post(User.class, user);
        
        System.out.println("User mise a jour : " + user.getPrenom() + " " + user.getNom());
        
        // test de recuperation de l'utilisateur
        r = c.resource(URL + "/" + user.getId());
        user = r.type(MediaType.APPLICATION_JSON_TYPE)
                .accept(MediaType.APPLICATION_JSON_TYPE)
                .get(User.class);
        
        System.out.println("User récupéré : " + user.getPrenom() + " " + user.getNom());
        
        // test de suppression des utilisateurs
        r = c.resource(URL + "/" + user.getId());
        r.type(MediaType.APPLICATION_JSON_TYPE)
                .accept(MediaType.APPLICATION_JSON_TYPE)
                .delete();
        
    }

}

Pour le test de récupération de la liste des utilisateurs, je n'ai malheureusement pas trouver de méthode élégante pour désérialiser directement le type List en JAX-RS comme cela est fait avec le type User... Une solution répandue consiste à retourner un wrapper encapsulant la liste des utilisateurs. Dans le prochain tutorial, nous aborderons nous verrons comment coder un client Android pour ces webservices avec l'utilisation d'une librairie nous permettant de désérialiser de manière élégante notre liste d'utilisateur. A la prochaine !
Fork me on GitHub