Vous êtes développeur Java au sein d’une équipe chargée de moderniser le système informatique d’une chaîne de restaurants. Vous devez concevoir et développer RestaurantAPI, une API REST Spring Boot qui permettra de gérer les plats, les menus, les tables et les commandes du restaurant.
Le projet sera développé en avec Spring Data JPA / Hibernate
Connectez-vous à PostgreSQL et exécutez le script suivant :
-- Créer la base de données CREATE DATABASE restaurant_db WITH ENCODING 'UTF8'; \c restaurant_db -- Catégories de plats CREATE TABLE categorie_plat ( id BIGSERIAL PRIMARY KEY, libelle VARCHAR(100) NOT NULL UNIQUE ); -- Plats du restaurant CREATE TABLE plat ( id BIGSERIAL PRIMARY KEY, nom VARCHAR(200) NOT NULL, description TEXT, prix DECIMAL(8, 2) NOT NULL CHECK (prix > 0), calories INTEGER, vegetarien BOOLEAN NOT NULL DEFAULT FALSE, disponible BOOLEAN NOT NULL DEFAULT TRUE, categorie_id BIGINT NOT NULL REFERENCES categorie_plat(id), date_creation TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Menus proposés CREATE TABLE menu ( id BIGSERIAL PRIMARY KEY, nom VARCHAR(200) NOT NULL, description TEXT, prix_fixe DECIMAL(8, 2), actif BOOLEAN NOT NULL DEFAULT TRUE, date_menu DATE ); -- Association menu ↔ plats CREATE TABLE menu_plat ( menu_id BIGINT NOT NULL REFERENCES menu(id) ON DELETE CASCADE, plat_id BIGINT NOT NULL REFERENCES plat(id), PRIMARY KEY (menu_id, plat_id) ); -- Tables du restaurant CREATE TABLE table_restaurant ( id BIGSERIAL PRIMARY KEY, numero INTEGER NOT NULL UNIQUE, capacite INTEGER NOT NULL CHECK (capacite > 0), statut VARCHAR(20) NOT NULL DEFAULT 'LIBRE' CHECK (statut IN ('LIBRE', 'OCCUPEE', 'RESERVEE')) ); -- Commandes CREATE TABLE commande ( id BIGSERIAL PRIMARY KEY, table_id BIGINT REFERENCES table_restaurant(id), statut VARCHAR(20) NOT NULL DEFAULT 'EN_COURS' CHECK (statut IN ('EN_COURS','SERVIE','PAYEE','ANNULEE')), date_commande TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, montant_total DECIMAL(10, 2), nombre_couverts INTEGER NOT NULL DEFAULT 1, notes TEXT ); -- Lignes de commande CREATE TABLE ligne_commande ( id BIGSERIAL PRIMARY KEY, commande_id BIGINT NOT NULL REFERENCES commande(id) ON DELETE CASCADE, plat_id BIGINT NOT NULL REFERENCES plat(id), quantite INTEGER NOT NULL DEFAULT 1 CHECK (quantite > 0), prix_unitaire DECIMAL(8, 2) NOT NULL, notes TEXT ); -- ─── Données initiales ─────────────────────────────────────────────────────── INSERT INTO categorie_plat (libelle) VALUES ('Entrées'), ('Plats principaux'), ('Desserts'), ('Boissons'), ('Fromages'); INSERT INTO plat (nom, description, prix, calories, vegetarien, categorie_id) VALUES ('Salade César', 'Laitue romaine, croûtons, parmesan', 8.50, 320, TRUE, 1), ('Soupe à l''oignon', 'Gratinée au gruyère', 7.00, 280, TRUE, 1), ('Velouté de poireaux','Velouté maison, crème fraîche', 6.50, 220, TRUE, 1), ('Steak Frites', 'Entrecôte 250g, frites maison', 18.90, 850, FALSE, 2), ('Saumon Grillé', 'Saumon atlantique, légumes de saison', 22.50, 520, FALSE, 2), ('Risotto Truffe', 'Riz arborio, truffe noire, parmesan', 24.00, 580, TRUE, 2), ('Poulet Rôti', 'Poulet fermier, pommes de terre', 15.90, 680, FALSE, 2), ('Fondant Chocolat', 'Cœur coulant, glace vanille', 7.50, 480, TRUE, 3), ('Crème Brûlée', 'Recette traditionnelle', 6.50, 380, TRUE, 3), ('Tarte Tatin', 'Pommes caramélisées, crème fraîche', 6.90, 420, TRUE, 3), ('Eau Minérale 75cl', 'Plate ou gazeuse', 3.00, 0, TRUE, 4), ('Vin Rouge 25cl', 'Sélection du sommelier', 6.50, 210, TRUE, 4), ('Jus de Fruits', 'Orange pressée maison', 4.50, 95, TRUE, 4); INSERT INTO table_restaurant (numero, capacite, statut) VALUES (1, 2, 'LIBRE'), (2, 4, 'LIBRE'), (3, 4, 'OCCUPEE'), (4, 6, 'LIBRE'), (5, 8, 'LIBRE'), (6, 2, 'RESERVEE');
Rendez-vous sur https://start.spring.io et configurez :
fr.formation
restaurant-api
fr.formation.restaurant
Dépendances à sélectionner :
Téléchargez, décompressez et importez dans votre IDE.
Créez ou modifiez src/main/resources/application.properties :
src/main/resources/application.properties
# Base de données PostgreSQL spring.datasource.url=jdbc:postgresql://localhost:5432/restaurant_db spring.datasource.username=VOTRE_UTILISATEUR spring.datasource.password=VOTRE_MOT_DE_PASSE # JPA / Hibernate spring.jpa.hibernate.ddl-auto=validate spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true # Logging logging.level.fr.formation=DEBUG logging.level.org.springframework.jdbc.core=DEBUG
Ou en version *.yaml avec application.yml :
application.yml
# ------------------------------------------------------------------- # application.yml — Configuration principale (MySQL) # ------------------------------------------------------------------- spring: # ----------------------------------------------------------------- # Base de données MySQL # ----------------------------------------------------------------- datasource: url: jdbc:mysql://localhost:3306/restaurant_spring?useSSL=false&serverTimezone=Europe/Paris&characterEncoding=UTF-8 username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver # --------------------------------------------------------------- # Pool de connexions HikariCP # --------------------------------------------------------------- hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 # ----------------------------------------------------------------- # JPA / Hibernate # ----------------------------------------------------------------- jpa: hibernate: # update = crée/modifie les tables manquantes (développement) # validate = valide le schéma sans modification (production) ddl-auto: update show-sql: true properties: hibernate: format_sql: true default_batch_fetch_size: 16 # ----------------------------------------------------------------- # Profil Spring actif (y en a pas d'autres pour le moment) # ----------------------------------------------------------------- profiles: active: dev # ------------------------------------------------------------------- # Logs # ------------------------------------------------------------------- logging: level: org: hibernate: SQL: DEBUG orm: jdbc: bind: TRACE fr: formation: DEBUG
Créez src/test/resources/application.properties pour les tests :
src/test/resources/application.properties
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL spring.datasource.driver-class-name=org.h2.Driver spring.datasource.username=sa spring.datasource.password= spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=false
Ajoutez aussi la dépendance H2 dans le pom.xml pour les tests :
pom.xml
<dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>test</scope> </dependency>
J’avais prévu cette étape, mais elle n’est pas importante, nous n’avons pas suffisamment de temps pour le faire.
Créez la classe Plat dans fr.formation.restaurant.model :
Plat
fr.formation.restaurant.model
id
Long
plat.id
nom
String
plat.nom
description
plat.description
prix
BigDecimal
plat.prix
calories
Integer
plat.calories
vegetarien
boolean
plat.vegetarien
disponible
plat.disponible
categorieId
plat.categorie_id
categorieLibelle
dateCreation
LocalDateTime
plat.date_creation
Utilisez Lombok : @Data, @Builder, @NoArgsConstructor, @AllArgsConstructor.
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
Créez PlatJdbcRepository dans fr.formation.restaurant.repository.jdbc avec JdbcTemplate.
PlatJdbcRepository
fr.formation.restaurant.repository.jdbc
JdbcTemplate
Méthodes obligatoires :
List<Plat> findAll(); List<Plat> findByDisponible(boolean disponible); List<Plat> findByCategorie(Long categorieId); List<Plat> findByVegetarien(boolean vegetarien); List<Plat> searchByNom(String terme); // ILIKE %terme% Optional<Plat> findById(Long id); Plat save(Plat plat); // INSERT ou UPDATE boolean deleteById(Long id); // soft delete (disponible=false) long count();
Conseil : Utilisez une jointure pour récupérer le libelle de la catégorie :
libelle
SELECT p.*, cp.libelle AS categorie_libelle FROM plat p JOIN categorie_plat cp ON p.categorie_id = cp.id WHERE p.disponible = TRUE ORDER BY cp.libelle, p.nom
Créez PlatService dans fr.formation.restaurant.service :
PlatService
fr.formation.restaurant.service
@Service @Transactional public class PlatService { // Méthodes à implémenter : List<Plat> trouverTous(); List<Plat> trouverDisponibles(); List<Plat> trouverParCategorie(Long categorieId); List<Plat> trouverVegetariens(); List<Plat> rechercher(String terme); Plat trouverParId(Long id); // lève PlatNotFoundException si absent Plat creer(Plat plat); // vérifie que le nom est unique Plat mettreAJour(Long id, Plat modifications); void desactiver(Long id); }
Créez PlatController dans fr.formation.restaurant.controller :
PlatController
fr.formation.restaurant.controller
/api/plats
/api/plats/{id}
/api/plats?vegetarien=true
/api/plats?categorie={id}
/api/plats/recherche?q={terme}
DTO de création à valider :
public record CreerPlatRequete( @NotBlank(message = "Le nom est obligatoire") @Size(max = 200) String nom, String description, @NotNull @DecimalMin("0.01") BigDecimal prix, @Min(0) Integer calories, boolean vegetarien, @NotNull(message = "La catégorie est obligatoire") Long categorieId ) {}
DTO de réponse :
public record PlatReponse( Long id, String nom, String description, BigDecimal prix, Integer calories, boolean vegetarien, boolean disponible, String categorie ) {}
Les entités suivantes doivent être annotées avec annotations JPA déjà vues :
Plat : transformer en entité JPA avec @Entity, @Table, @Id, @Column, @Enumerated,…
@Entity
@Table
@Id
@Column
@Enumerated
Commande :
Commande
@Entity @Table(name = "commande") public class Commande { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "table_id") private TableRestaurant table; @Enumerated(EnumType.STRING) @Column(nullable = false) private StatutCommande statut; @Column(name = "date_commande") private LocalDateTime dateCommande; @Column(name = "montant_total") private BigDecimal montantTotal; @Column(name = "nombre_couverts") private int nombreCouverts; private String notes; @OneToMany(mappedBy = "commande", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List<LigneCommande> lignes = new ArrayList<>(); }
LigneCommande et TableRestaurant : à implémenter de façon similaire.
LigneCommande
TableRestaurant
Enum StatutCommande : EN_COURS, SERVIE, PAYEE, ANNULEE
StatutCommande
EN_COURS
SERVIE
PAYEE
ANNULEE
PlatJpaRepository :
PlatJpaRepository
@Repository public interface PlatJpaRepository extends JpaRepository<Plat, Long> { List<Plat> findByDisponibleTrue(); List<Plat> findByVegetarienTrueAndDisponibleTrue(); List<Plat> findByNomContainingIgnoreCaseAndDisponibleTrue(String terme); boolean existsByNomIgnoreCase(String nom); long countByDisponibleTrue(); @Query("SELECT p FROM Plat p WHERE p.prix <= :max AND p.disponible = true ORDER BY p.prix") List<Plat> findByPrixMaximum(@Param("max") BigDecimal max); }
CommandeJpaRepository :
CommandeJpaRepository
@Repository public interface CommandeJpaRepository extends JpaRepository<Commande, Long> { List<Commande> findByStatut(StatutCommande statut); @Query("SELECT c FROM Commande c LEFT JOIN FETCH c.lignes l " + "LEFT JOIN FETCH l.plat WHERE c.id = :id") Optional<Commande> findByIdAvecLignes(@Param("id") Long id); List<Commande> findByTableIdAndStatutIn( Long tableId, List<StatutCommande> statuts); }
Créez CommandeService avec ces méthodes :
CommandeService
@Service @Transactional public class CommandeService { // Passer une nouvelle commande pour une table Commande passerCommande(Long tableId, int nombreCouverts, String notes); // Ajouter un plat à une commande en cours Commande ajouterPlat(Long commandeId, Long platId, int quantite, String notes); // Retirer un plat d'une commande Commande retirerPlat(Long commandeId, Long ligneId); // Calculer le montant total BigDecimal calculerTotal(Long commandeId); // Changer le statut (SERVIE → PAYEE, etc.) Commande changerStatut(Long commandeId, StatutCommande nouveauStatut); // Lister les commandes en cours List<Commande> trouverEnCours(); // Détail complet d'une commande Commande trouverParIdAvecLignes(Long id); }
Règles métier à implémenter :
disponible=false
TableNotFoundException
Créez PlatServiceTest dans src/test :
PlatServiceTest
src/test
@ExtendWith(MockitoExtension.class) class PlatServiceTest { @Mock private PlatJpaRepository platRepository; @InjectMocks private PlatService platService; // Tests à implémenter (minimum 10) : // trouverParId_idExistant_retournePlat() // trouverParId_idInexistant_levePlatNotFoundException() // creer_nomUnique_platSauvegarde() // creer_nomExistant_leveIllegalArgumentException() // creer_nomNull_leveException() // desactiver_platExistant_platDesactive() // desactiver_platInexistant_leveException() // trouverVegetariens_retourneSeulementVegetariens() // mettreAJour_platExistant_champsModifies() // rechercher_termeVide_retourneListe() }
Créez PlatRepositoryTest en utilisant @DataJpaTest :
PlatRepositoryTest
@DataJpaTest
@DataJpaTest class PlatRepositoryTest { // Minimum 6 tests : // findByDisponibleTrue_retourneSeulementDisponibles() // findByVegetarienTrue_retourneSeulementVegetariens() // findByNomContaining_rechercheInsensibleCasse() // existsByNomIgnoreCase_nomExistant_retourneTrue() // save_nouvauPlat_retourneAvecId() // findByPrixMaximum_retournePlatsMoinsChers() }
Créez PlatControllerTest en utilisant @WebMvcTest :
PlatControllerTest
@WebMvcTest
@WebMvcTest(PlatController.class) class PlatControllerTest { // Minimum 6 tests : // getAll_retourneListeEt200() // getById_platExistant_retourne200AvecDonnees() // getById_platInexistant_retourne404() // create_donneesValides_retourne201() // create_nomVide_retourne400() // delete_platExistant_retourne204() }
GET /api/commandes/{id}/addition
{ "commandeId": 1, "table": 3, "nombreCouverts": 2, "lignes": [ { "plat": "Steak Frites", "quantite": 2, "prixUnit": 18.90, "total": 37.80 }, { "plat": "Eau Minérale", "quantite": 2, "prixUnit": 3.00, "total": 6.00 } ], "sousTotal": 43.80, "total": 43.80, "dateCommande": "2024-03-15T20:30:00" }
Plats populaires : Endpoint GET /api/statistiques/plats-populaires qui retourne les 5 plats les plus commandés.
GET /api/statistiques/plats-populaires
Validation métier : Vérifier que les plats ajoutés à une commande sont disponibles (disponible=true).
disponible=true
Gestion des tables : Quand une commande passe à PAYEE, remettre la table en statut LIBRE.
LIBRE
# Lister tous les plats URL : http://localhost:8080/api/plats # Plats végétariens URL : "http://localhost:8080/api/plats?vegetarien=true" # Rechercher "saumon" URL : "http://localhost:8080/api/plats/recherche?q=saumon" # Créer un plat (POST) URL : http://localhost:8080/api/plats JSON : { "nom":"Pizza Margherita", "description":"Tomate, mozzarella", "prix":12.50, "vegetarien":true, "categorieId":2 } # Passer une commande (POST) URL : http://localhost:8080/api/commandes JSON : { "tableId":1, "nombreCouverts":2 } # Ajouter un plat à la commande (POST) URL : http://localhost:8080/api/commandes/1/lignes JSON : { "platId":4, "quantite":2 } # Calculer l'addition URL : http://localhost:8080/api/commandes/1/addition # Payer la commande (PUT) URL : http://localhost:8080/api/commandes/1/statut statut : "PAYEE"
ddl-auto=validate
update
create
Bonne chance !
Philippe Bouget — Formation Spring Boot · Java 17