Créer votre première REST API avec Spring Boot

1. Aperçu

Cet article vous guide dans la création d’une REST API complète avec Spring Boot, en utilisant l’exemple de gestion des Order (commandes). Après sa lecture, vous saurez créer le projet, écrire un contrôleur pour gérer les opérations CRUD, retourner des erreurs conformes aux normes HTTP, et rédiger des tests pour l’API avec MockMvc.

2. Initialisation du projet

Créez le projet via Spring Initializr avec les dépendances suivantes :

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

spring-boot-starter-web fournit Tomcat intégré et Spring MVC. spring-boot-starter-validation est utilisé pour valider le corps de la requête (section 5). La dépendance spring-boot-starter-test inclut JUnit 5, AssertJ, MockMvc.

Vérifiez que l’application fonctionne :

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

Exécutez mvn spring-boot:run, vous verrez Tomcat démarrer sur le port 8080.

3. Définition du modèle

public class Order {
    private Long id;
    private String customerName;
    private BigDecimal amount;

    public Order(Long id, String customerName, BigDecimal amount) {
        this.id = id;
        this.customerName = customerName;
        this.amount = amount;
    }

    // getters, setters
}

C’est un modèle simple, sans JPA. L’objectif de cet article est de maîtriser d’abord la couche REST, nous réserverons la partie persistence pour un autre article.

4. Écrire le Service pour la sauvegarde temporaire

@Service
public class OrderService {

    private final Map<Long, Order> store = new ConcurrentHashMap<>();
    private final AtomicLong idSequence = new AtomicLong(0);

    public Order create(Order order) {
        long id = idSequence.incrementAndGet();
        Order saved = new Order(id, order.getCustomerName(), order.getAmount());
        store.put(id, saved);
        return saved;
    }

    public List<Order> findAll() {
        return new ArrayList<>(store.values());
    }

    public Optional<Order> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    public void deleteById(Long id) {
        store.remove(id);
    }
}

ConcurrentHashMap joue le rôle d’une “base de données” temporaire, vous permettant d’exécuter la démo sans configurer une BD. AtomicLong génère des identifiants croissants, sûr quand plusieurs threads appellent create simultanément.

Note : Cette approche de sauvegarde des données est uniquement destinée à l’apprentissage, les données sont perdues au redémarrage de l’application. En production, vous avez besoin de Spring Data JPA ou d’un équivalent.

5. Écrire le Contrôleur REST

5.1. Endpoint de création et de récupération de la liste

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<Order> create(@Valid @RequestBody OrderRequest request) {
        Order order = new Order(null, request.customerName(), request.amount());
        Order saved = orderService.create(order);
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @GetMapping
    public List<Order> findAll() {
        return orderService.findAll();
    }
}

@RestController combine @Controller et @ResponseBody, retournant JSON au lieu d’une vue. @RequestMapping("/api/orders") attache un préfixe commun à tous les endpoints de la classe. @PostMapping reçoit le corps via @RequestBody, Spring convertit automatiquement JSON en OrderRequest grâce à Jackson inclus dans spring-boot-starter-web.

OrderRequest est un record utilisé pour valider l’entrée, séparé de Order :

public record OrderRequest(
        @NotBlank String customerName,
        @NotNull @Positive BigDecimal amount
) {}

5.2. Endpoint de récupération par id et suppression

@GetMapping("/{id}")
public ResponseEntity<Order> findById(@PathVariable Long id) {
    return orderService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
    orderService.deleteById(id);
    return ResponseEntity.noContent().build();
}

@PathVariable récupère la valeur {id} depuis l’URL. Quand la commande n’est pas trouvée, l’API retourne 404 Not Found au lieu de null — c’est un point facilement oublié quand on débute avec les REST API.

Testez avec curl :

curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"Nguyen Van A","amount":150000}'
HTTP/1.1 201 Created
Content-Type: application/json

{"id":1,"customerName":"Nguyen Van A","amount":150000}

6. Gestion centralisée des erreurs

Sans traitement approprié, les erreurs de validation retourneront une réponse JSON par défaut difficile à lire. Utilisez @RestControllerAdvice pour standardiser :

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors()
                .forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }
}

Cette classe capture chaque MethodArgumentNotValidException levée par @Valid, regroupe les erreurs par champ, puis retourne 400 Bad Request avec un corps lisible :

{
  "amount": "must be greater than 0",
  "customerName": "must not be blank"
}

Note : Ne laissez pas les exceptions par défaut de Spring s’exposer en production — elles pourraient révéler la pile d’appels, affectant la sécurité.

7. Rédiger des tests pour le Contrôleur

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturn201WhenCreateOrderSuccessfully() throws Exception {
        Order saved = new Order(1L, "Tran Thi B", new BigDecimal("200000"));
        given(orderService.create(any(Order.class))).willReturn(saved);

        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"customerName":"Tran Thi B","amount":200000}
                                """))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.customerName").value("Tran Thi B"));
    }

    @Test
    void shouldReturn404WhenOrderNotFound() throws Exception {
        given(orderService.findById(99L)).willReturn(Optional.empty());

        mockMvc.perform(get("/api/orders/99"))
                .andExpect(status().isNotFound());
    }
}

@WebMvcTest charge uniquement la couche web (contrôleur, filtre, validation), ne crée pas le contexte complet, donc les tests s’exécutent rapidement. @MockBean remplace le vrai OrderService par un mock, permettant de tester le contrôleur indépendamment de la logique du service. Les deux cas de test vérifient à la fois le chemin de succès et le chemin d’erreur — une habitude à avoir pour chaque endpoint.

Vérifiez également avec AssertJ au niveau du service :

@Test
void shouldGenerateIncrementalIdWhenCreatingMultipleOrders() {
    OrderService service = new OrderService();

    Order first = service.create(new Order(null, "A", BigDecimal.TEN));
    Order second = service.create(new Order(null, "B", BigDecimal.ONE));

    assertThat(first.getId()).isEqualTo(1L);
    assertThat(second.getId()).isEqualTo(2L);
}

8. Erreurs courantes

  • Oublier @RequestBody : Spring ne liera pas JSON à l’objet, les champs resteront null sans message d’erreur clair.
  • Retourner null au lieu de ResponseEntity.notFound() : le client reçoit 200 OK avec un corps null, ce qui peut causer des bugs côté frontend.
  • Ne pas valider l’entrée : l’API accepte des montants négatifs, des noms vides… causant des erreurs logiques aux niveaux inférieurs difficiles à tracer.
  • Utiliser @Controller au lieu de @RestController : vous devez ajouter @ResponseBody manuellement à chaque méthode, facile d’oublier.

Note : Si votre API sert plusieurs clients (mobile, web, partenaire), envisagez la versioning dès le départ (par exemple /api/v1/orders) pour éviter les breaking changes ultérieures.

9. Conclusion

Vous avez construit une REST API de base avec Spring Boot : du modèle, service, contrôleur, gestion centralisée des erreurs aux tests avec MockMvc. L’étape suivante, vous devriez remplacer ConcurrentHashMap par Spring Data JPA pour une vraie persistence des données, et ajouter à @RestControllerAdvice la gestion d’autres exceptions comme EntityNotFoundException.

spring-bootrest-apijavaspring-web