Branche fusion : API REST Spring Boot pour le front React

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Aubert Marvin
2026-06-12 10:50:45 +02:00
parent ee299e1e19
commit e67de45a3e
17 changed files with 465 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
# MyLibrary — backend Java
Backend métier du projet librairie (Maxime Lebreton / Marvin Aubert / Patrick Felix-Vimalaratnam).
Pendant visuel : dépôt **`2026-DEV-BUT3`** (React).
## Stack
- Java 21, Maven
- Couche domaine : livres, clients, points de fidélité
- **API REST** (branche `fusion`) : Spring Boot sur le port **8080**
## Lancer lAPI
```bash
mvn spring-boot:run
```
Vérifier : [http://localhost:8080/api/health](http://localhost:8080/api/health)
## Endpoints exposés au front
| Méthode | Route | Rôle |
|---------|-------|------|
| GET | `/api/health` | Ping |
| GET | `/api/books` | Liste des livres (format UI) |
| POST | `/api/books` | Créer un livre |
| GET | `/api/books/{isbn}` | Détail |
| DELETE | `/api/books/{isbn}` | Supprimer |
| POST | `/api/books/{isbn}/read` | Basculer lu / non lu |
| GET | `/api/users/{uuid}/loyalty-points` | Points fidélité client |
Au démarrage, deux livres démo + un client Marie Dupont (100 pts) sont chargés si le catalogue est vide.
## Brancher le front React
Dans `2026-DEV-BUT3` (branche `fusion`) :
```bash
cp .env.example .env
npm install
npm run dev
```
Le proxy Vite envoie `/api` vers `localhost:8080`. Commandes, promos, réservations, avis, abo, prêts et groupes restent côté front (local) tant que ces use cases ne sont pas exposés ici.
## Tests unitaires
```bash
mvn test
```
+19
View File
@@ -15,6 +15,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Main dependencies -->
<spring.boot.version>3.4.1</spring.boot.version>
<lombok.version>1.18.36</lombok.version>
<!-- Test Verisons-->
@@ -48,6 +49,12 @@
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@@ -146,6 +153,18 @@
</properties>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring.boot.version}</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,12 @@
package fr.iut_fbleau.but3.dev62.mylibrary;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MyLibraryApplication {
public static void main(String[] args) {
SpringApplication.run(MyLibraryApplication.class, args);
}
}
@@ -0,0 +1,23 @@
package fr.iut_fbleau.but3.dev62.mylibrary.api;
import fr.iut_fbleau.but3.dev62.mylibrary.book.exception.BookNotFoundException;
import fr.iut_fbleau.but3.dev62.mylibrary.book.exception.NotValidBookException;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<Map<String, String>> handleBookNotFound(BookNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(NotValidBookException.class)
public ResponseEntity<Map<String, String>> handleNotValidBook(NotValidBookException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", ex.getMessage()));
}
}
@@ -0,0 +1,86 @@
package fr.iut_fbleau.but3.dev62.mylibrary.api;
import fr.iut_fbleau.but3.dev62.mylibrary.api.dto.FrontendBookRequest;
import fr.iut_fbleau.but3.dev62.mylibrary.api.dto.FrontendBookResponse;
import fr.iut_fbleau.but3.dev62.mylibrary.book.BookDTO;
import fr.iut_fbleau.but3.dev62.mylibrary.book.exception.BookNotFoundException;
import fr.iut_fbleau.but3.dev62.mylibrary.book.exception.NotValidBookException;
import fr.iut_fbleau.but3.dev62.mylibrary.book.usecase.BookUseCase;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookUseCase bookUseCase;
private final Map<String, Boolean> readStatusByIsbn = new ConcurrentHashMap<>();
public BookController(BookUseCase bookUseCase) {
this.bookUseCase = bookUseCase;
}
@GetMapping
public List<FrontendBookResponse> listBooks() {
return bookUseCase.findAllBooks().stream()
.map(book -> FrontendBookMapper.toResponse(book, readStatusByIsbn.getOrDefault(book.getIsbn(), false)))
.toList();
}
@GetMapping("/{id}")
public ResponseEntity<FrontendBookResponse> getBook(@PathVariable String id) {
return bookUseCase.findBookByIsbn(id)
.map(book -> FrontendBookMapper.toResponse(book, readStatusByIsbn.getOrDefault(id, false)))
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<?> createBook(@RequestBody FrontendBookRequest request)
throws NotValidBookException {
if (request.title() == null || request.title().isBlank()
|| request.author() == null || request.author().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "title and author are required"));
}
String isbn = FrontendBookMapper.generateIsbn();
bookUseCase.registerBook(
FrontendBookMapper.toBookInfo(isbn, request),
FrontendBookMapper.toBookSalesInfo(request),
FrontendBookMapper.toBookDetails(request));
readStatusByIsbn.put(isbn, request.read());
return bookUseCase.findBookByIsbn(isbn)
.map(book -> ResponseEntity.status(HttpStatus.CREATED)
.body(FrontendBookMapper.toResponse(book, request.read())))
.orElse(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteBook(@PathVariable String id) throws BookNotFoundException {
bookUseCase.deleteBook(id);
readStatusByIsbn.remove(id);
return ResponseEntity.noContent().build();
}
@PostMapping("/{id}/read")
public ResponseEntity<FrontendBookResponse> toggleRead(@PathVariable String id) {
return bookUseCase.findBookByIsbn(id)
.map(book -> {
boolean next = !readStatusByIsbn.getOrDefault(id, false);
readStatusByIsbn.put(id, next);
return ResponseEntity.ok(FrontendBookMapper.toResponse(book, next));
})
.orElse(ResponseEntity.notFound().build());
}
}
@@ -0,0 +1,31 @@
package fr.iut_fbleau.but3.dev62.mylibrary.api;
import fr.iut_fbleau.but3.dev62.mylibrary.api.dto.LoyaltyPointsResponse;
import fr.iut_fbleau.but3.dev62.mylibrary.customer.CustomerDTO;
import fr.iut_fbleau.but3.dev62.mylibrary.customer.usecase.CustomerUseCase;
import java.util.Optional;
import java.util.UUID;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/users")
public class CustomerController {
private final CustomerUseCase customerUseCase;
public CustomerController(CustomerUseCase customerUseCase) {
this.customerUseCase = customerUseCase;
}
@GetMapping("/{id}/loyalty-points")
public ResponseEntity<LoyaltyPointsResponse> getLoyaltyPoints(@PathVariable UUID id) {
Optional<CustomerDTO> customer = customerUseCase.findCustomerById(id);
return customer
.map(c -> ResponseEntity.ok(new LoyaltyPointsResponse(c.getId().toString(), c.getLoyaltyPoints())))
.orElse(ResponseEntity.notFound().build());
}
}
@@ -0,0 +1,67 @@
package fr.iut_fbleau.but3.dev62.mylibrary.api;
import fr.iut_fbleau.but3.dev62.mylibrary.api.dto.FrontendBookRequest;
import fr.iut_fbleau.but3.dev62.mylibrary.api.dto.FrontendBookResponse;
import fr.iut_fbleau.but3.dev62.mylibrary.book.BookDTO;
import fr.iut_fbleau.but3.dev62.mylibrary.book.BookDetails;
import fr.iut_fbleau.but3.dev62.mylibrary.book.BookInfo;
import fr.iut_fbleau.but3.dev62.mylibrary.book.BookSalesInfo;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicLong;
public final class FrontendBookMapper {
private static final AtomicLong ISBN_SEQUENCE = new AtomicLong(System.nanoTime() % 1_000_000_000L);
private FrontendBookMapper() {}
public static String generateIsbn() {
long value = ISBN_SEQUENCE.incrementAndGet() % 10_000_000_000L;
return String.format("978%010d", value);
}
public static BookInfo toBookInfo(String isbn, FrontendBookRequest request) {
int safeYear = request.year() > 0 ? request.year() : LocalDate.now().getYear();
return new BookInfo(
isbn,
request.title().trim(),
request.author().trim(),
"Ma librairie",
LocalDate.of(safeYear, 1, 1));
}
public static BookSalesInfo toBookSalesInfo(FrontendBookRequest request) {
return BookSalesInfo.builder()
.price(request.price())
.stock(1)
.build();
}
public static BookDetails toBookDetails(FrontendBookRequest request) {
ArrayList<String> categories = new ArrayList<>();
String genre = request.genre() == null ? "" : request.genre().trim();
categories.add(genre.isBlank() ? "Général" : genre);
return BookDetails.builder()
.categories(categories)
.description("")
.language("fr")
.build();
}
public static FrontendBookResponse toResponse(BookDTO book, boolean read) {
int year = book.getDate() != null ? book.getDate().getYear() : LocalDate.now().getYear();
String genre = book.getCategories() == null || book.getCategories().isEmpty()
? "Général"
: book.getCategories().getFirst();
return new FrontendBookResponse(
book.getIsbn(),
book.getTitle(),
book.getAuthor(),
year,
genre,
book.getPrice(),
read,
book.getStock() == null ? 0 : book.getStock());
}
}
@@ -0,0 +1,16 @@
package fr.iut_fbleau.but3.dev62.mylibrary.api;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class HealthController {
@GetMapping("/health")
public Map<String, String> health() {
return Map.of("status", "ok", "service", "mylibrary-backend");
}
}
@@ -0,0 +1,10 @@
package fr.iut_fbleau.but3.dev62.mylibrary.api.dto;
public record FrontendBookRequest(
String title,
String author,
int year,
String genre,
double price,
boolean read
) {}
@@ -0,0 +1,12 @@
package fr.iut_fbleau.but3.dev62.mylibrary.api.dto;
public record FrontendBookResponse(
String id,
String title,
String author,
int year,
String genre,
double price,
boolean read,
int stock
) {}
@@ -0,0 +1,3 @@
package fr.iut_fbleau.but3.dev62.mylibrary.api.dto;
public record LoyaltyPointsResponse(String userId, int points) {}
@@ -12,6 +12,7 @@ import fr.iut_fbleau.but3.dev62.mylibrary.book.exception.NotValidBookException;
import fr.iut_fbleau.but3.dev62.mylibrary.book.repository.BookRepository;
import fr.iut_fbleau.but3.dev62.mylibrary.book.validator.BookValidator;
import java.util.List;
import java.util.Optional;
public class BookUseCase {
@@ -32,6 +33,12 @@ public class BookUseCase {
return bookToRegistered.getIsbn();
}
public List<BookDTO> findAllBooks() {
return bookRepository.findAll().stream()
.map(BookConverter::ToDTO)
.toList();
}
public Optional<BookDTO> findBookByIsbn(String isbn) {
Optional<Book> optionalBook = bookRepository.findByIsbn(isbn);
return optionalBook.map(BookConverter::ToDTO);
@@ -0,0 +1,32 @@
package fr.iut_fbleau.but3.dev62.mylibrary.config;
import fr.iut_fbleau.but3.dev62.mylibrary.book.repository.BookRepository;
import fr.iut_fbleau.but3.dev62.mylibrary.book.usecase.BookUseCase;
import fr.iut_fbleau.but3.dev62.mylibrary.customer.repository.CustomerRepository;
import fr.iut_fbleau.but3.dev62.mylibrary.customer.usecase.CustomerUseCase;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AppConfig {
@Bean
BookRepository bookRepository() {
return new BookRepository();
}
@Bean
BookUseCase bookUseCase(BookRepository bookRepository) {
return new BookUseCase(bookRepository);
}
@Bean
CustomerRepository customerRepository() {
return new CustomerRepository();
}
@Bean
CustomerUseCase customerUseCase(CustomerRepository customerRepository) {
return new CustomerUseCase(customerRepository);
}
}
@@ -0,0 +1,74 @@
package fr.iut_fbleau.but3.dev62.mylibrary.config;
import fr.iut_fbleau.but3.dev62.mylibrary.book.BookDetails;
import fr.iut_fbleau.but3.dev62.mylibrary.book.BookInfo;
import fr.iut_fbleau.but3.dev62.mylibrary.book.BookSalesInfo;
import fr.iut_fbleau.but3.dev62.mylibrary.book.exception.NotValidBookException;
import fr.iut_fbleau.but3.dev62.mylibrary.book.usecase.BookUseCase;
import fr.iut_fbleau.but3.dev62.mylibrary.customer.CustomerInfo;
import fr.iut_fbleau.but3.dev62.mylibrary.customer.exception.NotValidCustomerException;
import fr.iut_fbleau.but3.dev62.mylibrary.customer.usecase.CustomerUseCase;
import java.time.LocalDate;
import java.util.ArrayList;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class DemoDataLoader implements ApplicationRunner {
private final BookUseCase bookUseCase;
private final CustomerUseCase customerUseCase;
public DemoDataLoader(BookUseCase bookUseCase, CustomerUseCase customerUseCase) {
this.bookUseCase = bookUseCase;
this.customerUseCase = customerUseCase;
}
@Override
public void run(ApplicationArguments args) throws NotValidBookException, NotValidCustomerException {
if (!bookUseCase.findAllBooks().isEmpty()) {
return;
}
registerBook(
"9782070612758",
"Le Petit Prince",
"Antoine de Saint-Exupéry",
LocalDate.of(1943, 1, 1),
8.5,
"Conte");
registerBook(
"9782070368228",
"1984",
"George Orwell",
LocalDate.of(1949, 1, 1),
9.9,
"Science-fiction");
customerUseCase.registerCustomer(new CustomerInfo("Marie", "Dupont", "0612345678"));
customerUseCase.addLoyaltyPoints(
customerUseCase.findCustomerByPhoneNumber("0612345678").orElseThrow().getId(),
100);
}
private void registerBook(
String isbn,
String title,
String author,
LocalDate date,
double price,
String category)
throws NotValidBookException {
ArrayList<String> categories = new ArrayList<>();
categories.add(category);
bookUseCase.registerBook(
new BookInfo(isbn, title, author, "Gallimard", date),
BookSalesInfo.builder().price(price).stock(5).build(),
BookDetails.builder()
.categories(categories)
.description("")
.language("fr")
.build());
}
}
@@ -0,0 +1,17 @@
package fr.iut_fbleau.but3.dev62.mylibrary.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173", "http://127.0.0.1:5173")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*");
}
}
@@ -32,6 +32,10 @@ public final class CustomerUseCase {
return optionalCustomer.map(CustomerConverter::toDTO);
}
public Optional<CustomerDTO> findCustomerById(UUID uuid) {
return customerRepository.findById(uuid).map(CustomerConverter::toDTO);
}
public CustomerDTO updateCustomer(UUID uuid, CustomerInfo customerInfo)
throws CustomerNotFoundException, NotValidCustomerException {
CustomerValidator.validate(customerInfo);
@@ -0,0 +1,2 @@
server.port=8080
spring.application.name=mylibrary