Block Image

In questo articolo metteremo in pratica quello che ci siamo detti nel post Richardson Maturity Model e livelli REST, ovvero implementeremo un Web Service RESTful di livello 3.

Per fare ciò, useremo Spring HATEOAS. Questo modulo di Spring ci permetterà di creare un Web Service REST conforme al livello 3 del Richardson Maturity Model.

Prerequisiti

  1. Aver installato una jdk (useremo la versione 8 ma va bene anche una successiva).
  2. Aver installato maven (https://maven.apache.org/install.html).
  3. Conoscere il Richardson Maturity Model.

Primo passo: importare le seguenti dipendenze Maven

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-hateoas</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.16</version>
        <scope>provided</scope>
    </dependency>

    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Usiamo JPA per le operazioni CRUD effettuate dai servizi REST, utilizziamo la libreria di Lombok per auto-generare getter, setter e gli altri soliti metodi di un Javabean e infine adoperiamo H2 come database.

Secondo passo: creimo le entities User e Car

Creiamo un sottopackage entities e lì scriviamo le classi che mapperanno le tabelle USER e CAR:

@Entity
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString
public class User implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String surname;

    //tax code - codice fiscale
    @Column(unique = true, nullable = false)
    @EqualsAndHashCode.Include
    private String code;

    private String address;

    @OneToMany(mappedBy = "user", cascade = { CascadeType.PERSIST, CascadeType.MERGE})
    @ToString.Exclude
    private List<Car> cars = new ArrayList<>();
}
@Entity
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString
public class Car implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    @EqualsAndHashCode.Include
    private String plate;

    private String name;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;
}

Analizziamo il codice:

  1. Con @Entity stiamo indicando a JPA che la classe mapperà una tabella del database.
  2. @Getter, @Setter, @EqualsAndHashCode e @ToString sono le annotation di Lombok che ci permettono di auto-generare

rispettivamente i metodi getter, setter, equals/hashcode e il toString. Con onlyExplicitlyIncluded = true inoltre indichiamo a Lombok che solo i campi annotati con @EqualsAndHashCode.Include devono essere confrontati nei metodi di equals e hashcode. 3. Con @Column(unique = true, nullable = false) diciamo a JPA che il campo code di User e il campo plate di Car devono essere UNIQUE e non-null. 4. Con @OneToMany e @ManyToOne creiamo la relazione bidirezionale uno a molti tra User e Car.

Nota 1: La documentazione di Hibernate consiglia di mappare nei metodi equals e hashcode i campi che nel db saranno UNIQUE.
Nota 2: Tutti i campi di una classe annotata con @Entity saranno mappati automaticamente come colonne sul db. Si usa l'annotation @Column solo quando dobbiamo specificare degli attributi all'interno dell'annotation, come unique, nullable. Inoltre, questi due attributi servono solo nella fase di creazione del db, cioè funzionano solo se facciamo creare le tabelle a JPA, altrimenti sono inutilizzati dal framework.

Terzo passo: creimo i repository delle entities con Spring Data Repository

All'interno del package repos, scriviamo le 2 interfacce repositories di User e Car:

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByCode(String code);
}
public interface CarRepository extends JpaRepository<Car, Long> {

    Optional<Car> findByPlate(String plate);
}

Notiamo che per UserRepository abbiamo aggiunto il metodo findByCode e per CarRepository il metodo findByPlate.
Questo perché preferiamo che si possa cercare una singola entità tramite un suo campo di dominio piuttosto che per il suo id. Così facendo, nascondiamo al client una implementazione del db (qual è la chiave primaria per ogni entity, di che tipo è, quale potrebbe essere la chiave primaria di un'altra entity) e abbiamo dei vantaggi in termine di sicurezza.


Quarto passo: creiamo i DTOs

All'interno del package dtos, creiamo i DTOs di User e Car.

@Relation(collectionRelation = "users")
@Data
@AllArgsConstructor
public class UserDTO extends RepresentationModel<UserDTO> {

    private String name;

    private String surname;

    private String code;

    private String address;
}
@Relation(collectionRelation = "cars")
@Data
@AllArgsConstructor
public class CarDTO extends RepresentationModel<CarDTO> {


    private String plate;

    private String name;

}

Notiamo che entrambi i DTOs estendono la classe della libreria Spring HATEOAS RepresentationModel.
Estendendola, possiamo aggiungere i link nei JSON che mapperanno queste classi Java, tipici dei servizi REST di livello 3 (stiamo praticamente integrando HATEOAS).
Con @Relation(collectionRelation = "users") indichiamo ad HATEOAS il nome root che si dovrà visualizzare nel JSON quando visualizzeremo la collection relativa alla classe User.

Inoltre omettiamo il campo id in quanto non vogliamo dare questa informazione al client.

Quinto passo: scriviamo i services

Creiamo i services per User e Car nel package services. Riportiamo qui solo quello relativo a User, ma alla fine dell'articolo trovate il link del progetto completo sul mio GitHub.

@Service
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;

    private final UserAssembler userAssembler;

    private final CarAssembler carAssembler;

    private final PagedResourcesAssembler pagedResourcesAssembler;


    public UserServiceImpl(UserRepository userRepository, UserAssembler userAssembler, PagedResourcesAssembler pagedResourcesAssembler, CarAssembler carAssembler) {
        this.userRepository = userRepository;
        this.userAssembler = userAssembler;
        this.carAssembler = carAssembler;
        this.pagedResourcesAssembler = pagedResourcesAssembler;
    }


    @Override
    public CollectionModel<UserDTO> findAll(int page, int size, String[] sort, String dir) {
        PageRequest pageRequest;
        Sort.Direction direction;
        if(sort == null) {
            pageRequest = PageRequest.of(page, size);
        }
        else {
            if(dir.equalsIgnoreCase("asc")) direction = Sort.Direction.ASC;
            else direction = Sort.Direction.DESC;
            pageRequest = PageRequest.of(page, size, Sort.by(direction, sort));
        }
        Page<User> users = userRepository.findAll(pageRequest);
        if(! CollectionUtils.isEmpty(users.getContent())) return pagedResourcesAssembler.toModel(users, userAssembler);
        return null;
    }

    @Override
    public UserDTO findByCode(String code) {
        User user = userRepository.findByCode(code).orElse(null);
        if(user != null) {
            return userAssembler.toModel(user);
        }
        return null;
    }

    @Override
    public CollectionModel<CarDTO> findUserCars(String code) {
        User user = userRepository.findByCode(code).orElse(null);
        if(user != null && (! CollectionUtils.isEmpty(user.getCars())) ) {
            return carAssembler.toCollectionModel(user.getCars());
        }
        return null;
    }

    @Transactional
    @Override
    public UserDTO insert(User user) {
        user.getCars().forEach(car -> car.setUser(user));
        return userAssembler.toModel(userRepository.save(user));
    }
}

Analizziamo il codice:

  1. Dichiariamo due variabili di tipo UserAssembler e CarAssembler. Nei passi successivi vedremo l'implementazione di queste 2 classi. Per ora

ci basti sapere che sono dei Converters usati da Spring HATEOAS per mappare le entities in DTOs. 2. Utilizziamo una variabile di tipo PagedResourcesAssembler di Spring, che ci permette di inserire nel JSON le informazioni relative alla paginazione. 3. Il metodo findAll accetta in input un interno page che rappresenta il numero di pagina (da 0 a n), un intero size che indica il numero di entità all'interno della pagina, una stringa sort (facoltativa) che indica un eventuale campo su cui la paginazione deve essere ordinata e infine una stringa dir che può rispettivamente valere asc o desc (ordinamento crescente o decrescente). 4. Gli altri metodi sono banali e non meritano un'analisi dettagliata.

Sesto passo: scriviamo le resources di User e Car

All'interno del package resources, creiamo i RestController di User e Car. Anche qui riportiamo solo quello relativo a User:

@RestController
@RequestMapping("/users")
public class UserResource {

    private UserService userService;

    public UserResource(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public ResponseEntity findAll(@RequestParam(required = false, defaultValue = "0") Integer page,
                                  @RequestParam(required = false, defaultValue = "3") Integer size,
                                  @RequestParam(required = false) String[] sort,
                                  @RequestParam(required = false, defaultValue = "asc") String dir) {

        CollectionModel<UserDTO> users = userService.findAll(page, size, sort, dir);
        if(users != null) {
            return ResponseEntity.ok(users);
        }
        return ResponseEntity.noContent().build();
    }


    @GetMapping("/{code}")
    public ResponseEntity findByCode(@PathVariable String code) {
        UserDTO userDTO = userService.findByCode(code);
        if(userDTO == null)  return ResponseEntity.notFound().build();

        return ResponseEntity.ok(userDTO);
    }

    @GetMapping("/{code}/cars")
    public ResponseEntity findUserCars(@PathVariable String code) {
        CollectionModel<CarDTO> cars = userService.findUserCars(code);
        if(cars != null) return ResponseEntity.ok(cars);

        return ResponseEntity.noContent().build();
    }

    @PostMapping
    public ResponseEntity insertUser(@RequestBody User user) {
        UserDTO userDTO = userService.insert(user);
        return ResponseEntity //
                .created(userDTO.getRequiredLink(IanaLinkRelations.SELF).toUri()) //
                .body(userDTO);
    }
}

Questa classe banalmente non contiene logica, ma richiama semplicemente la business logic dei services.
La risorsa User è mappata con /users, inoltre:

  1. La findAll è mappata come metodo GET con la URL /users. Se il servizio restituisce una collezione non vuota,

abbiamo un HTTP Response 200 OK, altrimenti un 204 No Content. 2. La findByCode è mappata come metodo GET con la URL /users/{code}. Se la risorsa viene trovata, il servizio restituisce un 200 OK con il body della entity, altrimenti viene restituito un 404 Not Found. 3. Il metodo GET /users/{code}/cars viene utilizzato per cercare le cars di un user con code fornito in input. 4. Il metodo insertUser viene mappato con la URL /users (POST). Restituisce un 201 Created, con body vuoto e nell'header viene aggiunto un campo Location con valore uguale all'URL della risorsa appena creata. Inoltre viene data la possibilità di inserire uno User insieme alle sue car grazie al Cascade di JPA.

I metodo PUT e DELETE sono banali per cui li omettiamo. Questi servizi dedicati alla risorsa user rispettano appieno le best practices del livello 3 del Richardson Maturity Model!

Settimo passo: scriviamo (finalmente) le classi Assemblers di User e Car

All'interno del package assemblers scriviamo le classi UserAssembler e CarAssembler che fanno da Converters "entity to dto" (riportiamo anche qui solo quella di User):

@Component
public class UserAssembler implements RepresentationModelAssembler<User, UserDTO> {


    @Override
    public UserDTO toModel(User entity) {
        UserDTO userDTO = new UserDTO(entity.getName(), entity.getSurname(), entity.getCode(), entity.getAddress());
        userDTO.add(linkTo(methodOn(UserResource.class).findUserCars(entity.getCode())).withRel("cars"));
        userDTO.add(linkTo(methodOn(UserResource.class).findByCode(entity.getCode())).withSelfRel());
        return userDTO;
    }
}

Molto semplicemente, data una entity User, creiamo col costruttore uno UserDTO e aggiungiamo, tramite i metodi add, linkTo, methodOn, il link per ricavare le cars dello User e quello self.
Inoltre, la classe contiene il metodo default toCollectionModel che permette di mappare una collection di entities in una collection di DTOs.

Ottavo passo: creiamo un application.properties e un file data.sql

application.properties:

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.username=sa
spring.datasource.password=

data.sql:

INSERT INTO user (id, name, surname, code, address) VALUES (1, 'Vincenzo', 'Racca', 'cf1', 'via Roma');
INSERT INTO user (id, name, surname, code, address) VALUES (2, 'Pippo', 'Pluto', 'cf2', 'via Chiaia');
INSERT INTO user (id, name, surname, code, address) VALUES (3, 'Gennaro', 'Esposito', 'cf3', 'via Napoli');
INSERT INTO user (id, name, surname, code, address) VALUES (4, 'Gennaro', 'Albergo', 'cf4', 'via Roma');
INSERT INTO user (id, name, surname, code, address) VALUES (5, 'Mario', 'Rossi', 'cf5', 'via Piave');

INSERT INTO car (plate, name, user_id) VALUES ('BF8013RR', 'Toyota', 1);
INSERT INTO car (plate, name, user_id) VALUES ('AF8013RR', 'Fiat', 1);
INSERT INTO car (plate, name, user_id) VALUES ('CF8013RR', 'Lancia', 2);
INSERT INTO car (plate, name, user_id) VALUES ('DF8013RR', 'Renault', 3);
INSERT INTO car (plate, name, user_id) VALUES ('EF8013RR', 'Smart', 3);

Analizziamo il file application.properties:

  1. Con spring.jpa.show-sql = true logghiamo le query di hibernate.
  2. Con spring.jpa.properties.hibernate.format_sql = true e spring.jpa.properties.hibernate.use_sql_comments = true formattiamo

le query in modo che siano più chiare da vedere. 3. Con spring.h2.console.enabled = true abilitiamo la console di H2 alla URL localhost:8080/h2-console per vedere i dati del database.

Nono passo: testiamo il Web Service

Avviamo la classe principale del progetto:

@SpringBootApplication
public class SpringHateoasApplication {

	public static void main(String[] args) {
		SpringApplication.run(SpringHateoasApplication.class, args);
	}

}

Chiamiamo il servizio findAll:
localhost:8080/users, avremo la seguente response:

{
    "_embedded": {
        "users": [
            {
                "name": "Vincenzo",
                "surname": "Racca",
                "code": "cf1",
                "address": "via Roma",
                "_links": {
                    "cars": {
                        "href": "http://localhost:8080/users/cf1/cars"
                    },
                    "self": {
                        "href": "http://localhost:8080/users/cf1"
                    }
                }
            },
            {
                "name": "Pippo",
                "surname": "Pluto",
                "code": "cf2",
                "address": "via Chiaia",
                "_links": {
                    "cars": {
                        "href": "http://localhost:8080/users/cf2/cars"
                    },
                    "self": {
                        "href": "http://localhost:8080/users/cf2"
                    }
                }
            },
            {
                "name": "Gennaro",
                "surname": "Esposito",
                "code": "cf3",
                "address": "via Napoli",
                "_links": {
                    "cars": {
                        "href": "http://localhost:8080/users/cf3/cars"
                    },
                    "self": {
                        "href": "http://localhost:8080/users/cf3"
                    }
                }
            }
        ]
    },
    "_links": {
        "first": {
            "href": "http://localhost:8080/users?page=0&size=3"
        },
        "self": {
            "href": "http://localhost:8080/users?page=0&size=3"
        },
        "next": {
            "href": "http://localhost:8080/users?page=1&size=3"
        },
        "last": {
            "href": "http://localhost:8080/users?page=1&size=3"
        }
    },
    "page": {
        "size": 3,
        "totalElements": 5,
        "totalPages": 2,
        "number": 0
    }
}

Notiamo che ogni entità della collezione ha il link per conoscere le cars di ognuno di loro e il link self.
Inoltre grazie alla classe PagedResourcesAssembler, la response contiene i link per la prima pagina, la prossima e l'ultima, oltre ad avere le informazione sulla page (size, totalElements, totalPages e number).

La response è paginata con 3 entities in quanto abbiamo nel Rest Controller questi valori di default:

public ResponseEntity findAll(@RequestParam(required = false, defaultValue = "0") Integer page,
                              @RequestParam(required = false, defaultValue = "3") Integer size,
                              @RequestParam(required = false) String[] sort,
                              @RequestParam(required = false, defaultValue = "asc") String dir)

Possiamo però personalizzare la richiesta di paginazione ad esempio in questo modo:
/users?page=0&size=2&sort=code

Facciamo una chiamata findByCode in questo modo:
http://localhost:8080/users/cf1, avremo questa response:

{
    "name": "Vincenzo",
    "surname": "Racca",
    "code": "cf1",
    "address": "via Roma",
    "_links": {
        "cars": {
            "href": "http://localhost:8080/users/cf1/cars"
        },
        "self": {
            "href": "http://localhost:8080/users/cf1"
        }
    }
}

Analizzimo dai log la query fatta da JPA:

select
    user0_.id as id1_1_,
    user0_.address as address2_1_,
    user0_.code as code3_1_,
    user0_.name as name4_1_,
    user0_.surname as surname5_1_ 
from
    user user0_ 
where
    user0_.code=?

Banalmente, la query è molto leggera perché coinvolge solo la tabella USER.
Il JSON di risposta però ci dà modo di conoscere anche le cars di questo user; facciamo la chiamata al link cars che ci ha restituito il JSON precedente:
http://localhost:8080/users/cf1/cars

JSON di risposta:

{
    "_embedded": {
        "cars": [
            {
                "plate": "BF8013RR",
                "name": "Toyota",
                "_links": {
                    "user": {
                        "href": "http://localhost:8080/cars/BF8013RR/user"
                    },
                    "self": {
                        "href": "http://localhost:8080/cars/BF8013RR"
                    }
                }
            },
            {
                "plate": "AF8013RR",
                "name": "Fiat",
                "_links": {
                    "user": {
                        "href": "http://localhost:8080/cars/AF8013RR/user"
                    },
                    "self": {
                        "href": "http://localhost:8080/cars/AF8013RR"
                    }
                }
            }
        ]
    }
}

Quindi in questo modo abbiamo il pro che le response body sono sempre leggere, perché paginate e perché le relazioni tra entità sono semplici link. Inoltre in questo modo il client può facilmente ricavare a cascata tutte le url delle entità dell'applicazione.

Di contro, il client dovrà effettuare più chiamare REST. Ma essendo le risposte appunto molto leggere, i pro battono ampliamente i contro.

Conclusioni

Abbiamo visto come creare un Web Service RESTful che rispetta le best practices del livello 3 del Richardson Maturity Model grazie all'aiuto di Spring HATEOAS.

Potete trovare il progetto completo sul mio github a questo link: Spring HATEOAS

Articoli su Spring: Spring