spring / kotlin

Qualité Algorithmique

Thibault Duperron

bigM

Decathlon Digital

Lead tech Backend

Slides & Exo

Spring - Histoire

snow white blow

Spring - Histoire

Diagram

Spring

Diagram

Spring Boot

Diagram

Spring vs Spring Boot

Spring ingredients

SpringBoot gateau

Spring vs Spring Boot

Spring ingredients

Spring Boot alsa

Injection de dépendances

di everywhere

Injection de dépendances

Diagram

Injection de dépendances

Diagram

Injection de dépendances

Diagram

Injection de dépendances

class AService(val dbAccess: DbAccess)

interface DbAccess
class PostgresDb: DbAccess
class MySqlDb: DbAccess
val appPg = AService(PostgresDb())
val appMy = AService(MySqlDb())

Injection de dépendances

class AService(val dbAccess: DbAccess)

interface DbAccess
class PostgresDb: DbAccess
class MySqlDb: DbAccess
val dbAccess = if (useMySql) MySqlDb() else PostgresDb()
val appPg = AService(dbAccess)

Beans

//
class MyConfig {








}

Beans

//
class MyConfig {
    @Bean
    fun myDb() = PostgresDb()






}

Beans

//
class MyConfig {
    @Bean
    fun myDb() = PostgresDb()

    @Bean
    fun aService(dbAccess: DbAccess) = AService(dbAccess)



}

Beans

//
class MyConfig {
    @Bean
    fun myDb() = PostgresDb()

    @Bean
    fun aService(dbAccess: DbAccess) = AService(dbAccess)

    @Bean
    fun restController(aService: AService) = RestController(aService)
}

Beans

@Configuration
class MyConfig {
    @Bean
    fun myDb() = PostgresDb()

    @Bean
    fun aService(dbAccess: DbAccess) = AService(dbAccess)

    @Bean
    fun restController(aService: AService) = RestController(aService)
}

External Beans

@Configuration
class MyConfig {
    @Bean
    fun myDb(aDriverFromALib: JdbcDriver) = GenericDb(aDriverFromALib)

    @Bean
    fun aService(dbAccess: DbAccess) = AService(dbAccess)

    @Bean
    fun restController(aService: AService) = RestController(aService)
}

Autowired

//
class RestController {
    @Autowired
    lateinit var aService: AService
}
@Service
class RestController(val aService: AService)

Autowired

@Service
class RestController {
    @Autowired
    lateinit var aService: AService
}
@Service
class RestController(val aService: AService)

Autowired

@Service
class RestController {
    @Autowired
    lateinit var aService: AService
}
@Service // @Component @Repository ...
class RestController(val aService: AService)

spring-boot-starter-mvc

curl -XGET localhost:8080/hello
Hello World

RestController

@RestController
class HelloController {



}

RestController

@RestController
class HelloController {

    @GetMapping("/hello")
    fun hello() = "Hello World"
}

Code !

keyboard

Initialisation du projet

start.spring.io

webmvc

Premier controller

Créer HelloController.kt dans un sous-package controller.

@RestController
class HelloController {

    @GetMapping("/hello")
    fun hello() = "world"
}

Lancer && tester

Lancer **Application.kt qui est à la racine du projet (clic droit → run).

Appeler GET localhost:8080/hello et vérifier que la réponse est bien world.

API Rest

GET /ponies/{name}/type

API Rest

GET /ponies/{name}/?type=earth

Lister

GET /ponies?age=42

Créer

POST /ponies

Obtenir un élément

GET /ponies/{name}

Mettre à jour un élément

PUT /ponies/{name}

Supprimer un élément

POST /ponies/{name}

Paramètres

@GetMapping("/hello")
fun queryParam(@RequestParam name: String) = "world of $name"
@GetMapping("/hello/{name}")
fun path(@PathVariable name: String) = "world of $name"
@PostMapping("/hello")
fun body(@RequestBody name: String) = "world of $name"
@GetMapping("/hello")
fun header(@RequestHeader name: String) = "world of $name"

Code retour

    @GetMapping("/hello/{name}")
    fun path(@PathVariable name: String) = "world of $name"

    @GetMapping("/hello/{name}")
    fun helloPath(@PathVariable name: String) =
        ResponseEntity.status(HttpStatus.OK).body("world of $name")

Code retour

    @GetMapping("/hello/{name}")
    fun path(@PathVariable name: String) = "world of $name"

    @GetMapping("/hello/{name}")
    fun helloPath(@PathVariable name: String) =
        ResponseEntity.ok("world of $name")

Code retour

    @GetMapping("/hello/{name}")
    fun helloPath(@PathVariable name: String) = if (name.length <= 2) {
        ResponseEntity.badRequest().body("Name size must be > 2")
    } else {
        ResponseEntity.ok("world of $name")
    }

DTO

Desing Pattern - Data Transfert Object

Objet simple représentant la donnée

Dans le cas d’une API REST / Json il ne doit pas contenir de cycle pour être sérialisable en json.

DTO

data class PersonDTO(val name: String, val age: Int)
@GetMapping("/hello")
fun hello() = ResponseEntity.ok(PersonDTO("John", 42))

Code !

keyboard

Code !

keyboard

CRUD

Le but de cet exercice est de créer un premier CRUD (Create, Read, Update, Delete).

Le CRUD doit manipuler des utilisateurs dont on a les informations: Nom, Prénom, Âge et Email.

Pour cette implémentation, une Map en mémoire permettra de faire office de base de données. La clé unique est l’adresse email.

L’implémentation se fera dans une classe UserController.

Create

Le premier endpoint POST /api/users qui prend le JSON d’un utilisateur, l’enregistre dans la Map et répond un HTTP 201 avec le contenu de l’utilisateur en body.

Exemple d’appel:

curl --location 'localhost:8080/api/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "firstName": "John",
    "lastName": "Doe",
    "age": 10,
    "email": "[email protected]"
}'

Create - Conflit

Un endpoint de création doit normalement signaler si la ressource existe déjà.

Modifier le endpoint pour que si on envoie deux fois la même adresse email, la réponse soit un HTTP 409 (conflit).

Exemple d’appel:

curl --location 'localhost:8080/api/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "firstName": "John",
    "lastName": "Doe",
    "age": 10,
    "email": "[email protected]"
}' \ &&
curl --location 'localhost:8080/api/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "firstName": "Another",
    "lastName": "Name",
    "age": 42,
    "email": "[email protected]"
}'

Read - Liste

Le premier endpoint de lecture est un endpoint de liste. Un appel à GET /api/users doit répondre 200 avec la liste des utilisateurs qui sont dans la Map.

Exemple d’appel:

curl --location 'localhost:8080/api/users'

Reponse:

[
    {
        "email": "[email protected]",
        "firstName": "John",
        "lastName": "Doe",
        "age": 10
    }
]

Read - Unique

Ajouter un endpoint GET /api/users/{email} qui retourne :

  • un status 200 avec le contenu de l’utilisateur s’il existe dans la Map,

  • un status 404 sinon.

Exemple d’appel:

curl --location 'localhost:8080/api/users/[email protected]'

Reponse:

{
    "email": "[email protected]",
    "firstName": "John",
    "lastName": "Doe",
    "age": 10
}

Update

Ajouter un endpoint PUT /api/users/{email} qui retourne :

  • un status 200 si l’utilisateur existe, met à jour l’utilisateur dans la Map et le retourne,

  • un status 400 sinon.

Exemple d’appel:

curl --location --request PUT 'localhost:8080/api/users/[email protected]' \
--header 'Content-Type: application/json' \
--data-raw '{
    "firstName": "John",
    "lastName": "Doe",
    "age": 42,
    "email": "[email protected]"
}'

Reponse:

{
    "email": "[email protected]",
    "firstName": "John",
    "lastName": "Doe",
    "age": 42
}

Delete

Ajouter un endpoint DELETE /api/users/{email} qui retourne :

  • un status 204 si l’utilisateur existe et le supprime de la Map,

  • un status 400 sinon.

Exemple d’appel:

curl --location --request DELETE 'localhost:8080/api/users/[email protected]'

Liste filtrée

Ajouter sur la liste des utilisateurs la possibilité de filtrer par âge.

Exemple d’appel:

curl --location 'localhost:8080/api/users?age=42'

Reponse:

[
    {
        "email": "[email protected]",
        "firstName": "John",
        "lastName": "Doe",
        "age": 42
    }
]

Séparation en service et DTO

Si ce n’est pas déjà fait, découper le code en 3:

  • UserController qui déclare le endpoint et fait une conversion UserDTO (monde externe) → User (domaine interne)

  • UserInMemoryRepository qui contient en propriété privée la Map

  • UserService qui contient le reste du code métier

/src
 /main
  /kotlin
   /monpackage
    /controller
     /dto
      UserDTO.kt
     UserController.kt
    /domain
     User.kt
    /service
     UserService.kt
    /repository
     UserInMemoryRepository.kt

Tests

code coverage

Pyramide des Tests

pyramide test

Tests unitaires

Ces tests ne sont pas liés à l’utilisation de spring.

On peut utiliser un framework de test au choix (junit, spock…​) et un système d’assertion au choix (junit, assertk…​).

Junit est embarqué dans les dépendances Spring.

Tests unitaires

@Service
class DummyService {

    fun doSomething(pony: String) = if (pony.length < 3) {
        "bad"
    } else {
        "good"
    }
}
class DummyServiceTest {

    private val service = DummyService()

    @Test
    fun `length gt 3 is good`() {
        // WHEN
       val result = service.doSomething("pony")
        // THEN
        assertThat(result).isEqualTo("good") // assertK
    }
}

Tests unitaires

@TestInstance(PER_CLASS)
class PonyTest {
    @BeforeAll
    fun beforeAll() = println("before all")
    @BeforeEach
    fun beforeEach() = println("before each")
    @Test
    fun test1() = println("test1")
    @Test
    fun test2() = println("test2")
    @AfterEach
    fun afterEach() = println("after each")
    @AfterAll
    fun afterAll() = println("after all")
}
before all
before each
test1
after each
before each
test2
after each
after all

Tests d’intégration

@SpringBootTest
class DummyServiceTest {
    @Autowired
    private lateinit var service: DummyService

    @Test
    fun `length gt 3 is good`() {
        // WHEN
       val result = service.doSomething("pony")
        // THEN
        assertThat(result).isEqualTo("good") // assertK
    }
}

warning

@SpringBootTest ne fonctionne que dans un sous package de l’application @SpringBootApplication

warning

Bouchons (mock) pour test d’integration

interface Dependency {
    fun call(): Boolean
}
@Service
class DummyService(val dependency: Dependency) {

    fun callDep(pony: String) = if (dependency.call()) {
        "good"
    } else {
        "bad"
    }
}

Mockk (version Kotlin de Mockito)

@SpringBootTest
class DummyServiceIntTest {
    @Autowired
    private lateinit var service: DummyService

    @Test
    fun `call good`() {
        // WHEN
        val result = service.callDep("pony")
        // THEN
        assertThat(result).isEqualTo("good")
    }
}

Mockk (version Kotlin de Mockito)

@SpringBootTest
class DummyServiceIntTest {
    @MockkBean
    private lateinit var dependency: Dependency
    @Autowired
    private lateinit var service: DummyService

    @Test
    fun `call good`() {
        // WHEN
        val result = service.callDep("pony")
        // THEN
        assertThat(result).isEqualTo("good")
    }
}

Mockk (version Kotlin de Mockito)

@SpringBootTest
class DummyServiceIntTest {
    @MockkBean
    private lateinit var dependency: Dependency
    @Autowired
    private lateinit var service: DummyService

    @Test
    fun `call good`() {
        // GIVEN
        every { dependency.call() } returns true
        // WHEN
        val result = service.callDep("pony")
        // THEN
        assertThat(result).isEqualTo("good")
    }
}

Every

every { dependency.call() } returns true
every { dependency.call(Pony("name") } returns "23"
every { dependency.call(any(), any()) } throws Exception("Nope")

Code !

keyboard

Libs

testImplementation("com.willowtreeapps.assertk:assertk:0.27.0")
testImplementation("io.mockk:mockk:1.13.8")
testImplementation("com.ninja-squad:springmockk:4.0.2")

Tests unitaires

Sans utiliser @SpringBootTest, ajouter une couverture de tests unitaire à 100% sur UserInMemoryRepository.

Tests d’intégration

En utilisant @SpringBootTest, ajouter une couverture de tests à 100% sur UserController en utilisant un mock de UserInMemoryRepository.

Hibernate x Spring

spring-boot-starter-validation

Permet d’ajouter de la validation sur les paramètres des méthodes.

Validation globale

@RestController
@Validated
class DemoController(val demoRepository: DemoRepository) {
  @GetMapping
  fun list(@RequestParam(required = false) @Size(min=2, max=20) name: String?)
      = if (name == null) ...

Validation du body

@Valid @RequestBody demo: DemoDTO
data class DemoDTO(
        val id: UUID = UUID.randomUUID(),
        @field:Size(min=5, max=10)
        val name: String,
)

Gestion des erreurs

Les erreurs de validation n’entrent pas dans le code de la fonction.

La gestion est externe et générique.

ControllerAdvice

@ControllerAdvice
class HttpErrorHandler {




}

ControllerAdvice

@ControllerAdvice
class HttpErrorHandler {


    fun handleMethodArgumentNotValid(e: MethodArgumentNotValidException) =
        ResponseEntity.badRequest().body("You're arg is invalid")
}

ControllerAdvice

@ControllerAdvice
class HttpErrorHandler {

    @ExceptionHandler(MethodArgumentNotValidExceptSion::class)
    fun handleMethodArgumentNotValid(e: MethodArgumentNotValidException) =
        ResponseEntity.badRequest().body("You're arg is invalid")
}

ControllerAdvice - Spring

@ControllerAdvice
class HttpErrorHandler : ResponseEntityExceptionHandler() {

  override fun handleMethodArgumentNotValid(e: MethodArgumentNotValidException,
                                            headers: HttpHeaders,
                                            status: HttpStatusCode,
                                            request: WebRequest)
                                            : ResponseEntity<Any>? {
      return ResponseEntity.badRequest().body("You're arg is invalid")
  }
}

Tests

Spring propose des tests de couche (layer).

Ces tests ne lancent qu’une partie de l’application.

Pour la partie web il faut remplacer @SpringBootTest par @WebMvcTest.

WebMvcTest

@WebMvcTest(DemoController::class)
class DemoControllerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Test
    fun get() {
        mockMvc.get("/api/demo")
                .andExpect {
                    status { isOk() }
                    content { jsonPath("$.[0].name", `is`("name")) }
                }
    }
}

WebMvcTest

fun post() {
    // GIVEN
    every { demoRepository.save(any()) } returns DemoEntity(name = "name")
    mockMvc.post("/api/demo") {
        contentType = MediaType.APPLICATION_JSON
        content = ObjectMapper().writeValueAsString(DemoEntity(name = "name"))
    }
            .andExpect {
                status { isOk() }
                content { jsonPath("$.name", `is`("name")) }
            }
}

Code !

keyboard

Validation

Ajouter de la validation :

  • le nom doit faire entre 2 et 30 caractères,

  • le prénom ne doit pas être vide,

  • l’âge doit être supérieur à 15,

  • l’email doit avoir un format valide.

Gestion des erreurs

Ajouter une class ControllerAdvice dans le package monpackage.config

Ce ControllerAdvice doit prendre les erreurs de validation et retourner une réponse avec dans le body:

  • le message d’erreur

  • le code http

  • le timestamp (date.now)

Gestion des erreurs

Dans le cas où le repository ne trouve pas l’utilisateur, lancer une exception UserNotFound.

Traiter l’exception dans le controllerAdvice

Tests

Faire une couverture des cas nominaux et des cas d’erreurs de UserController en utilisant WebMvcTest.

Round 2

Configuration de Spring

À la racine du dossier ressources.

application.properties

application.yml

Configuration de Spring

application-dev.properties

application-dev.yml

Ordre

En cas de conflit, la valeur prise est celle du dernier profil.

En yaml, on peut supprimer une valeur avec ~.

Activation d’un profil

VM option :

-Dspring.profiles.active=dev

Intellij ultimate fournit un champ pour les rajouter directement.

Accès aux propriétés

@Value("\${custom.value:defaultValue}")
private lateinit var value: String

Accès aux propriétés

@ConfigurationProperties("custom-prop")
data class CustomProperties(val demo: String)
@Configuration
@EnableConfigurationProperties(CustomProperties::class)
class PropertiesConfig
Ajouter spring-boot-configuration-processor pour avoir la génération de l’auto-complétion dans les fichiers de configuration.

Accès aux propriétés

@Configuration
class DatabaseConfig {
    @ConditionalOnProperty("db.external",
                           havingValue = "false",
                           matchIfMissing = true)
    @Bean
    fun inMemory() = UserInMemoryRepository()
}

Code !

keyboard

Profils dans Info

En utilisant l’approche @Value, faire un endpoint /info qui donne la liste des profils Spring actifs (la valeur passée en variable d’environnement).

La réponse de /info doit contenir "profiles": "dev" en cas de profil dev "profiles": "" sinon.

Exemple d’appel:

curl --location 'localhost:8080/api/info'

Reponse sans profile:

{
    "profiles": []
}

Reponse avec le profile dev:

{
    "profiles": ["dev"]
}

Info custom

Ajouter au fichier de propriétés :

iut:
  spring:
    exo: 5

En utilisant l’approche @ConfigurationProperties, ajouter le contenu au endpoint qui doit donc répondre :

{
    "profile": "dev",
    "iut": {
        "spring": {
            "exo": 5
        }
    }
}

Accès à la base de données

Spring Data JPA

jOOQ

MyBatis

DAO

Data Access Object

Design pattern pour séparer l’accès aux données persistantes.

Architecture n-tier

Diagram

JPA

Java Persistence API

Interface de programmation Java permettant d’organiser des données relationnelles.

JSR / Jakarta

Architecture n-tier

Diagram

Spring Data JPA

spring-boot-starter-data-jpa

+

driver jdbc

=

Data Access Object (DAO) généré

Architecture n-tier

Diagram

Entity

Objet qui a une identité indépendante des changements de ses attributs.

Utilisé pour représenter un objet en base de données.

Entity

import jakarta.persistence.*



data class DemoEntity(


        val id: Int?,
        val name: String,
)

Entity

import jakarta.persistence.*

@Entity

data class DemoEntity(


        val id: Int?,
        val name: String,
)

Entity

import jakarta.persistence.*

@Entity
@Table(name = "Demo")
data class DemoEntity(


        val id: Int?,
        val name: String,
)

Entity

import jakarta.persistence.*

@Entity
@Table(name = "Demo")
data class DemoEntity(
        @Id

        val id: Int?,
        val name: String,
)

Entity

import jakarta.persistence.*

@Entity
@Table(name = "Demo")
data class DemoEntity(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val id: Int?,
        val name: String,
)

Entity

import jakarta.persistence.*

@Entity
@Table(name = "Demo")
data class DemoEntity(
        @Id

        val id: UUID = UUID.randomUUID(),
        val name: String,
)

Jpa Repository

interface JpaRepository<ENTITY, ID>
interface DemoRepository : JpaRepository<DemoEntity, UUID>

Jpa Repository

fun save(entity: T): T

fun findAll(): List<T>;

fun findById(id: ID): Optional<T>

fun deleteById(id: ID): Unit

fun deleteAll(): Unit

Jpa Repository Custom Query

interface DemoRepository : JpaRepository<DemoEntity, UUID> {

    fun findAllByName(name: String): List<DemoEntity>
}

Jpa Custom Query

@Query(value = "SELECT d from DemoEntity d where d.name = :name")
fun manual(name: String): List<DemoEntity>
@Query(value = """SELECT d from DemoEntity d
    where (:name is null or d.name = :name)""")
fun manual(name: String?): List<DemoEntity>

Jpa Criteria

interface DemoRepositoryCustom {
    fun criteria(name: String?): List<DemoEntity>
}

interface DemoRepository :
        JpaRepository<DemoEntity, UUID>,
        DemoRepositoryCustom

Jpa Criteria

class DemoRepositoryCustomImpl : DemoRepositoryCustom {
    @PersistenceContext
    private lateinit var entityManager: EntityManager

    override fun criteria(name: String?): List<DemoEntity> {
        val criteriaBuilder = entityManager.criteriaBuilder
        val queryBuilder = criteriaBuilder.createQuery(DemoEntity::class.java)
        val root: Root<DemoEntity> = queryBuilder.from(DemoEntity::class.java)
        var query = queryBuilder.select(root)
        if (name != null) {
            val nameField: Path<DemoEntity> = root.get("name")
            query = query.where(criteriaBuilder.equal(nameField, name))
        }
        return entityManager.createQuery(query).resultList
    }
}

Test Jpa

Spring propose des tests de "Layer".

Ces tests ne lancent qu’une partie de l’application.

Pour JPA, il faut remplacer @SpringBootTest par @DataJpaTest.

Test Jpa

@DataJpaTest
class DemoRepositoryTest {
    @Autowired
    private lateinit var jpaRepository: DemoRepository

    @Test
    fun `find one existing`() {
       // GIVEN
        jpaRepository.save(DemoEntity(randomUUID(), "name"))
        // WHEN
        val result = jpaRepository.findAllByName("name")
        // THEN
        assertThat(result).hasSize(1)
    }
}

Code !

keyboard

Ajout des dépendances

jpa

Bien prendre tous les ajouts !

Préparation

Extraire une interface UserRepository de UserInMemoryRepository.

JPA

Créer une classe UserDatabaseRepository qui implémente UserRepository et qui utilise un JpaRepository pour interroger la base de données.

Pour choisir entre l’implementation in-memory ou h2, remplacer l’annotation @Repository par un fichier de configuration avec des @ConditionalOnProperty

Utiliser le JPA par nom de méthode pour la liste des utilisateurs

Tests

Couvrir avec des tests UserDatabaseRepository.

UserDatabaseRepository peut être créer dans un BeforeEach.

Jointures

En SQL pour gérer des données hiérarchiques on utilise des jointures.

En JPA elles sont représenté par quatres annotations:

  • @OneToOne

  • @OneToMany

  • @ManyToOne

  • @ManyToMany

Cascades

La cascade est la propagation d’une modification aux enfants de l’entité.

Si l’objet A contient l’objet B, lors d’un "update" de A en base, je peux vouloir modifier/ajouter/supprimer l’objet B ou ignorer toutes les modifications de B

Direction

Une relation peut être uni-directionnel ie je ne peux aller que de l’objet A vers l’objet B

ou bi-directionnel ie je peux aller de A à B et de B à A.

Join-Column

L’annotation @JoinColumn permet de fournir à hibernate des informations sur la manière de lier les entités.

name: nom de la foreign key

referencedColumnName : le nom de la colonne de l’autre entité utilisé pour la jointure.

@JoinColumn(referencedColumnName = "email")

One-To-One

@Entity
@Table(name = "users")
class UserEntity(
        @Id val email: String,
        @OneToOne(cascade = [CascadeType.ALL])
        @JoinColumn(referencedColumnName = "email")
        val phone: PhoneEntity,
) {
@Entity
@Table(name = "phone")
class PhoneEntity(
        @Id // Doit être unique, peut aussi être un @Column(unique = true)
        val email: String,
        val number: String,
)

One-To-One

class UserEntity(
        @Id val email: String,
        @OneToOne(cascade = [CascadeType.ALL])
        @JoinColumn(name = "fk_email")
        var phone: PhoneEntity?,
)

class PhoneEntity(
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Int?,
        @OneToOne(mappedBy = "phone")
        val user: UserEntity,
        val number: String,
)

One-To-One

class UserEntity(
        @Id val email: String,
        @OneToOne(mappedBy = "user", cascade = [CascadeType.ALL])
        var phone: PhoneEntity?,
)


class PhoneEntity(
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Int?,
        @OneToOne
        val user: UserEntity,
        val number: String,
)

One-To-Many

class UserEntity(
        @Id val email: String,
        @OneToMany(cascade = [CascadeType.ALL])
        @JoinColumn(referencedColumnName = "email")
        val phones: List<PhoneEntity> = emptyList(),
)

class PhoneEntity(
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Int?,
        val email: String,
        val number: String,
)

Many-To-One

class UserEntity(
        @Id val email: String,
        @OneToMany(cascade = [CascadeType.ALL], mappedBy = "user")
        var phones: List<PhoneEntity> = emptyList(),
)

class PhoneEntity(
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Int?,
        @ManyToOne
        @JoinColumn(name="fk_email")
        val user: UserEntity?,
        val number: String,
)

Many-To-Many

class UserEntity(
        @Id val email: String,
        @ManyToMany(cascade = [CascadeType.ALL])
        @JoinTable(
                name = "user_phone",
                joinColumns = [JoinColumn(name = "email")],
                inverseJoinColumns = [JoinColumn(name = "id")])
        var phones: List<PhoneEntity> = emptyList(),
)
class PhoneEntity(
        @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Int?,
        @ManyToMany
        val user: List<UserEntity>,
        val number: String,
)

Code !

keyboard

One-to-Many

Ajouter pour chaque utilisateur une liste de numéro de téléphones

Exemple d’appel:

curl --location 'localhost:8080/api/users'

Reponse:

[
    {
        "email": "[email protected]",
        "firstName": "John",
        "lastName": "Doe",
        "age": 10,
        "phones": [
            {
                "type": "personal",
                "number": "0611223344"
            }
        ]
    }
]

Many-to-Many

Ajouter pour chaque utilisateur une liste d’amis, chaque amis est un autre utilisateur

Exemple d’appel:

curl --location 'localhost:8080/api/users'

Reponse:

[
    {
        "email": "[email protected]",
        "firstName": "John",
        "lastName": "Doe",
        "age": 30,
        "phones": [
            {
                "type": "personal",
                "number": "0611223344"
            }
        ],
        "friends": {
            "email": "[email protected]",
            "firstName": "Pinkie",
            "lastName": "Pie"
        }
    },
    {
        "email": "[email protected]",
        "firstName": "Pinkie",
        "lastName": "Pie"
        "age": 19,
        "phones": [],
        "friends": {
            "email": "[email protected]",
            "firstName": "John",
            "lastName": "Doe"
        }
    }
]

@Query

En utilisant @Query, faire une API qui retourne tous les utilisateurs avec au moins un numéro de téléphone

Round 3

Actuators

Pour être "production ready" une application doit être en mesure de fournir :

  • un health-check,

  • des metrics,

  • des logs.

Actionneurs Spring

Ajouter la dépendance Spring fournit directement plusieurs endpoints sous /actuator.

endpointsdescription

/actuator/health

santé de l’application

/actuator/info

information général sur l’application

/actuator/metrics

métriques de l’application

/actuator/beans

liste des beans et de leur dépendance

Configuration des actuators

management:
  endpoints:
    web:
      exposure:
        include: info, health
'*' expose tous les enpoints.

Customisation des actuators

@Component
@EndpointWebExtension(endpoint = InfoEndpoint::class)
class CustomInfo(val delegate: InfoEndpoint) {

    @ReadOperation
    fun info(): WebEndpointResponse<Map<*, *>> {
        val info = this.delegate.info()
        info["custom.value"] = "pony"
        return WebEndpointResponse(info, 200)
    }
}

Metrics

micrometer-registry-prometheus

@Component
class MetricsConfig(meterRegistry: MeterRegistry) {
    private val myCount = meterRegistry.counter("name.my.count",
                                                "aDimension", "theValue")

    override fun theIncrement() {
        myCount.increment()
    }

Logs

De base dans Spring :

slf4j + logback

Optionel :

oshai:kotlin-logging.

Niveaux de log

  • ERROR

  • WARN

  • INFO

  • DEBUG

  • TRACE

Niveaux de log

logging:
  level:
    org.springframework.web: DEBUG
    bzh.zomzog.prez: WARN

logback

logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
  <include resource="org/springframework/boot/logging/logback/console-appender.xml" />
  <root level="INFO">
    <appender-ref ref="CONSOLE" />
  </root>
  <logger name="org.springframework.web" level="DEBUG"/>
</configuration>

logback

<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
  <withJansi>true</withJansi>
  <encoder>
    <pattern>[%thread] %highlight(%-5level) %cyan(%logger{15}) -%kvp -%msg %n</pattern>
  </encoder>
</appender>
<root level="DEBUG">
  <appender-ref ref="STDOUT" />
</root>

logger

private val logger = LoggerFactory.getLogger(javaClass)
logger.trace("trace of ${name}")
logger.warn("warning with exception", Exception())

Code !

keyboard

Logs

Modifier les logs pour qu’ils soient sous la forme :

INFO - class - threadId - message

Ajouter des logs info au début des appels HTTP et de debug dans les repositories.

Actuator

Ajouter la dépendance actuator à votre projet et vérifier qu’elle fonctionne.

Les endpoints sont sous localhost:8080/actuator.

Par exemple : localhost:8080/actuator/info.

Ajouter le contenu du endpoint précédent /api/info dans la réponse du /actuator/info

Sécurité

Certains endpoints de l’actuators exposent des données sensibles, voire sont dangereux.

En modifiant les propriétés

  • Sans profil, il ne doit y avoir que : health, info et metrics.

  • Avec le profil dev : shutdown est disponible.

Changer le port des actuators pour le port 9001.

Metrics

Ajouter une métrique api.call qui s’incrémente à chaque appel des endpoints. Utiliser les dimensions pour faire la difference entre GET/PUT/POST/DELETE

OpenAPI Specification 3

oas3

OAS3 ?

Un IDL (Interface Definition Language) pour décrire des APIs.

Le but est de fournir un contrat entre producteur et consommateur de l’API.

format: YAML ou JSON.

Info

openapi: 3.0.3
info:
  title: OAS Petstore - OpenAPI 3.0
  description: PetStore API
  termsOfService: http://swagger.io/terms/
  contact:
    email: [email protected]
  license:
    name: Apache 2.0
    url: http://www.apache.org/licenses/LICENSE-2.0.html
  version: 1.0.11
servers:
  - url: https://localhost:8080/api/v1

Tags

tags:
  - name: pet
    description: Everything about your Pets
    externalDocs:
      description: Find out more
      url: http://swagger.io
  - name: store
    description: Access to Petstore orders
    externalDocs:
      description: Find out more about our store
      url: http://swagger.io
  - name: user
    description: Operations about user

Path

paths:
  /pet/{petId}:
    put:
      tags:
        - pet
      summary: Update an existing pet
      description: Update an existing pet by Id
      operationId: updatePet
      parameters:
        - name: petId
          in: path
          description: ID of pet to return
          required: true
          schema:
            type: integer
            format: int64
      requestBody:
        description: Update an existent pet in the store
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Pet'
        required: true
      responses:
        '200':
          description: Successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
        '400':
          description: Invalid ID supplied
      security:
        - petstore_auth:
            - write:pets
            - read:pets

Path

paths:
  /pet/{petId}:
    put:
      tags:
        - pet
      summary: Update an existing pet
      description: Update an existing pet by Id
      operationId: updatePet
      security:
        - petstore_auth:
            - write:pets
            - read:pets

Path

      parameters:
        - name: petId
          in: path
          description: ID of pet to return
          required: true
          schema:
            type: integer
            format: int64

Path

      requestBody:
        description: Update an existent pet in the store
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Pet'
          application/xml:
            schema:
              $ref: '#/components/schemas/Pet'
        required: true

Path

      responses:
        '200':
          description: Successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
        '400':
          description: Invalid ID supplied
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

Components

components:
  schemas:
    Pet:
      required:
        - name
      type: object
      properties:
        id:
          type: integer
          format: int64
          example: 10
        name:
          type: string
          example: doggie

Visualisation

Swagger UI

swagger ui

Génération VS Génération

codecontract

Génération depuis le code

springdoc-openapi-starter-webmvc-ui

Avec juste l’ajout de la dépendance on a :

Personnalisation

@Operation(summary = "List users")
@ApiResponses(value = [
    ApiResponse(responseCode = "200", description = "List users",
            content = [Content(mediaType = "application/json",
                    array = ArraySchema(
                            schema = Schema(implementation = UserDTO::class)))])])

Personnalisation

@Parameter(description = "id of book to be searched")
@PathVariable id: UUID

Code !

keyboard

OAS3

Rajouter la dépendance springdoc-openapi-starter-webmvc-ui.

Vérifier que swagger-ui et l’OAS3 sont accessibles.

Documentation

Ajouter la documentation des endpoints de UserController avec:

  • une description de chaque endpoints

  • une description de chaque parametre

  • une description des réponses http

  • des tags READ/WRITE suivant les endpoints

RestTemplate

Spring fournis un client HTTP :

val restTemplate = RestTemplate()
val result: ResponseEntity<List<UserDTO>> =
  restTemplate.getForEntity("http://localhost:9999/api/users")
if (result.statusCode.is2xxSuccessful) {
    return result.body
} else {
    throw Exception("Fail")
}

RestTemplate

Simplification des appels :

val restTemplate = RestTemplateBuilder().rootUri("http://localhost:9999")
                                        .build()
val result: ResponseEntity<List<UserDTO>> =
              restTemplate.getForEntity("/api/users")

Body

val body = UserDTO("email", "name", "last", 1)
val entity = HttpEntity(body)
val single = restTemplate
              .postForEntity("/api/users", entity, UserDTO::class.java)
val multi = restTemplate
              .postForEntity("/api/users", entity, Array<UserDTO>::class.java)

Code !

keyboard

Dummy-app

Le projet correction contient l’application dummy-app qui se lance sur le port 9999.

C’est un micro service de géstion de poneys.

CRUD

Il expose un CRUD classique:

  • GET /api/ponies

  • POST /api/ponies { "name": "Le Nom", "type": "EARTH" }

  • GET /api/ponies/{id}

  • PUT /api/ponies/{id} { "name": "Le Nom", "type": "EARTH" }

  • DELETE /api/ponies/{id}

RestTemplate

En base ajouter un PonyId à votre UserEntity.

En utilisant un client RestTemplate:

  • lors de la création d’un utilisateur, faire la création du poney et sauvegarder son ID en BDD

  • lors de la récupération d’un utilisateur, aller chercher le détail sur dummy-app

  • lors de la suppression d’un utilisateur, supprimer le poney

Json

[
    {
        "email": "[email protected]",
        (...)
        "pony": {
          "id": 1,
          "name": "Pinkie Pie",
          "type": "EARTH"
        }
    }
]

Bean

RestTemplate est un bon candidat pour un bean.

Déplacer sa création en tant que bean dans une classe RestTemplateConfig. La rootUri doit être configurable en properties.

Unicité

Dummy-app ne peut pas avoir deux poneys avec le même nom.

Ajouter un contrôle pour ne créer le poney que s’il n’existe pas et le supprimer uniquement si aucun autre utilisateur n’y est lié.

Round 4

WebClient / WebFlux

org.springframework.boot:spring-boot-starter-webflux

Mono<Pony> : 1 item

Flux<Pony> : n items

Mono donnée ou Erreur

Mono.error<String>(Exception())
	.map(it -> it + "1") // non appelé
Mono.just("Data")

WebClient creation

val wc = WebClient.builder().baseUrl("http://localhost:9997")
        .defaultHeaders {
            it.add("name", "value")
        }
        .build()

WebClient usage

wc.get()
  .uri { uriBuilder: UriBuilder ->
      uriBuilder.path("/api/ponies")
  	    .build()
  }.retrieve()
  .bodyToMono<String>()
  .block()

Erreurs

wc.get()
  .uri { ... }.retrieve()
  .onStatus(
  	{ status: HttpStatusCode -> status.isError },
  	{ response -> Mono.error(
		ResponseStatusException(response.statusCode(), "the error"))
	}
  )
  .bodyToMono<String>()
  .block()

Retry

wc.get()
  ...
  .bodyToMono<String>()
  .retryWhen(Retry.backoff(5, Duration.ofSeconds(1))
  	.filter {
  	    when (it) {
  		is ResponseStatusException -> it.statusCode.is5xxServerError
  		else -> false
  	    }
  	})
  .block()

Code !

keyboard

Dummy-app

Reprendre l’exercice 10 en remplaçant le RestTemplate par un WebClient