Aller au contenu principal

Intégration Spring Boot

Exemple complet d'intégration de l'API MONCRENEAU avec Spring Boot 3.x.

Structure du Projet

moncreneau-spring-boot/
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/com/example/moncreneau/
│ │ │ ├── MonCreneauApplication.java
│ │ │ ├── config/
│ │ │ │ ├── MonCreneauConfig.java
│ │ │ │ └── SecurityConfig.java
│ │ │ ├── controller/
│ │ │ │ ├── AppointmentController.java
│ │ │ │ └── WebhookController.java
│ │ │ ├── service/
│ │ │ │ ├── MonCreneauService.java
│ │ │ │ └── WebhookService.java
│ │ │ ├── model/
│ │ │ │ ├── Appointment.java
│ │ │ │ └── WebhookEvent.java
│ │ │ └── exception/
│ │ │ └── MonCreneauException.java
│ │ └── resources/
│ │ └── application.properties
│ └── test/
│ └── java/com/example/moncreneau/
│ ├── controller/
│ │ └── WebhookControllerTest.java
│ └── service/
│ └── MonCreneauServiceTest.java
└── README.md

Configuration

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.1</version>
<relativePath/>
</parent>

<groupId>com.example</groupId>
<artifactId>moncreneau-spring-boot</artifactId>
<version>1.0.0</version>
<name>MONCRENEAU Spring Boot Integration</name>

<properties>
<java.version>17</java.version>
</properties>

<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Spring Boot Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<!-- Jackson -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>

<!-- Test Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

application.properties

# Application
spring.application.name=moncreneau-integration

# Server
server.port=8080

# MONCRENEAU API
moncreneau.api.url=https://mc-prd.duckdns.org/api/v1
moncreneau.api.key=${MONCRENEAU_API_KEY}
moncreneau.webhook.secret=${MONCRENEAU_WEBHOOK_SECRET}

# Logging
logging.level.com.example.moncreneau=INFO
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n

Configuration Spring

MonCreneauConfig.java

package com.example.moncreneau.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class MonCreneauConfig {

@Value("${moncreneau.api.url}")
private String apiUrl;

@Value("${moncreneau.api.key}")
private String apiKey;

@Value("${moncreneau.webhook.secret}")
private String webhookSecret;

@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}

public String getApiUrl() {
return apiUrl;
}

public String getApiKey() {
return apiKey;
}

public String getWebhookSecret() {
return webhookSecret;
}
}

Service Layer

MonCreneauService.java

package com.example.moncreneau.service;

import com.example.moncreneau.config.MonCreneauConfig;
import com.example.moncreneau.exception.MonCreneauException;
import com.example.moncreneau.model.Appointment;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@Service
public class MonCreneauService {

private final RestTemplate restTemplate;
private final MonCreneauConfig config;

public MonCreneauService(RestTemplate restTemplate, MonCreneauConfig config) {
this.restTemplate = restTemplate;
this.config = config;
}

public Appointment createAppointment(Map<String, Object> appointmentData) {
String url = config.getApiUrl() + "/appointments";

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + config.getApiKey());

HttpEntity<Map<String, Object>> request = new HttpEntity<>(appointmentData, headers);

try {
log.info("Creating appointment: organizationId={}",
appointmentData.get("organizationId"));

ResponseEntity<Appointment> response = restTemplate.exchange(
url,
HttpMethod.POST,
request,
Appointment.class
);

Appointment appointment = response.getBody();
log.info("Appointment created: id={}", appointment.getId());

return appointment;

} catch (HttpClientErrorException e) {
log.error("Failed to create appointment: {}", e.getMessage());

if (e.getStatusCode() == HttpStatus.PAYMENT_REQUIRED) {
throw new MonCreneauException("Insufficient credits", e);
} else if (e.getStatusCode() == HttpStatus.BAD_REQUEST) {
throw new MonCreneauException("Invalid appointment data", e);
}

throw new MonCreneauException("API error: " + e.getMessage(), e);
}
}

public Appointment getAppointment(Long appointmentId) {
String url = config.getApiUrl() + "/appointments/" + appointmentId;

HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + config.getApiKey());

HttpEntity<Void> request = new HttpEntity<>(headers);

try {
ResponseEntity<Appointment> response = restTemplate.exchange(
url,
HttpMethod.GET,
request,
Appointment.class
);

return response.getBody();

} catch (HttpClientErrorException e) {
log.error("Failed to get appointment {}: {}", appointmentId, e.getMessage());
throw new MonCreneauException("Failed to retrieve appointment", e);
}
}

public void cancelAppointment(Long appointmentId) {
String url = config.getApiUrl() + "/appointments/" + appointmentId;

HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + config.getApiKey());

HttpEntity<Void> request = new HttpEntity<>(headers);

try {
log.info("Cancelling appointment: id={}", appointmentId);

restTemplate.exchange(
url,
HttpMethod.DELETE,
request,
Void.class
);

log.info("Appointment cancelled: id={}", appointmentId);

} catch (HttpClientErrorException e) {
log.error("Failed to cancel appointment {}: {}", appointmentId, e.getMessage());
throw new MonCreneauException("Failed to cancel appointment", e);
}
}
}

WebhookService.java

package com.example.moncreneau.service;

import com.example.moncreneau.config.MonCreneauConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;

@Slf4j
@Service
public class WebhookService {

private final MonCreneauConfig config;

public WebhookService(MonCreneauConfig config) {
this.config = config;
}

public boolean verifySignature(String payload, String signature) {
if (signature == null || !signature.startsWith("sha256=")) {
log.warn("Invalid signature format");
return false;
}

String receivedHash = signature.substring(7);

try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
config.getWebhookSecret().getBytes("UTF-8"),
"HmacSHA256"
);
mac.init(secretKey);

byte[] expectedBytes = mac.doFinal(payload.getBytes("UTF-8"));
String expectedHash = bytesToHex(expectedBytes);

// Timing-safe comparison
boolean isValid = MessageDigest.isEqual(
expectedHash.getBytes(),
receivedHash.getBytes()
);

if (!isValid) {
log.warn("Signature verification failed");
}

return isValid;

} catch (Exception e) {
log.error("Signature verification error", e);
return false;
}
}

public void processWebhookEvent(String eventType, Map<String, Object> eventData) {
log.info("Processing webhook event: type={}", eventType);

switch (eventType) {
case "appointment.created":
handleAppointmentCreated(eventData);
break;
case "appointment.updated":
handleAppointmentUpdated(eventData);
break;
case "appointment.cancelled":
handleAppointmentCancelled(eventData);
break;
case "appointment.validated":
handleAppointmentValidated(eventData);
break;
case "appointment.completed":
handleAppointmentCompleted(eventData);
break;
default:
log.warn("Unknown event type: {}", eventType);
}
}

private void handleAppointmentCreated(Map<String, Object> eventData) {
log.info("Handling appointment.created");
// Envoyer email de confirmation
// Envoyer SMS
// Mettre à jour la base de données locale
}

private void handleAppointmentUpdated(Map<String, Object> eventData) {
log.info("Handling appointment.updated");
// Notifier l'utilisateur du changement
}

private void handleAppointmentCancelled(Map<String, Object> eventData) {
log.info("Handling appointment.cancelled");
// Envoyer email d'annulation
// Libérer les ressources
}

private void handleAppointmentValidated(Map<String, Object> eventData) {
log.info("Handling appointment.validated");
// Confirmer au personnel
}

private void handleAppointmentCompleted(Map<String, Object> eventData) {
log.info("Handling appointment.completed");
// Envoyer enquête de satisfaction
}

private String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
}

Controllers

AppointmentController.java

package com.example.moncreneau.controller;

import com.example.moncreneau.model.Appointment;
import com.example.moncreneau.service.MonCreneauService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/api/appointments")
public class AppointmentController {

private final MonCreneauService monCreneauService;

public AppointmentController(MonCreneauService monCreneauService) {
this.monCreneauService = monCreneauService;
}

@PostMapping
public ResponseEntity<Appointment> createAppointment(
@RequestBody Map<String, Object> appointmentData) {

Appointment appointment = monCreneauService.createAppointment(appointmentData);
return ResponseEntity.status(HttpStatus.CREATED).body(appointment);
}

@GetMapping("/{id}")
public ResponseEntity<Appointment> getAppointment(@PathVariable Long id) {
Appointment appointment = monCreneauService.getAppointment(id);
return ResponseEntity.ok(appointment);
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> cancelAppointment(@PathVariable Long id) {
monCreneauService.cancelAppointment(id);
return ResponseEntity.ok().build();
}
}

WebhookController.java

package com.example.moncreneau.controller;

import com.example.moncreneau.service.WebhookService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/webhooks")
public class WebhookController {

private final WebhookService webhookService;
private final ObjectMapper objectMapper;

public WebhookController(WebhookService webhookService, ObjectMapper objectMapper) {
this.webhookService = webhookService;
this.objectMapper = objectMapper;
}

@PostMapping("/moncreneau")
public ResponseEntity<String> handleWebhook(
@RequestBody String payload,
@RequestHeader("X-Moncreneau-Signature") String signature) {

// Vérifier la signature
if (!webhookService.verifySignature(payload, signature)) {
log.warn("Invalid webhook signature");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Invalid signature");
}

try {
// Parser le payload
Map<String, Object> event = objectMapper.readValue(payload, Map.class);

String eventType = (String) event.get("type");
Map<String, Object> eventData = (Map<String, Object>) event.get("data");

// Traiter l'événement de manière asynchrone
// Dans un vrai projet, utiliser @Async ou une queue
webhookService.processWebhookEvent(eventType, eventData);

return ResponseEntity.ok("OK");

} catch (Exception e) {
log.error("Error processing webhook", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Error processing webhook");
}
}
}

Models

Appointment.java

package com.example.moncreneau.model;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;

import java.time.LocalDateTime;

@Data
public class Appointment {
private Long id;

@JsonProperty("departmentId")
private Long departmentId;

@JsonProperty("dateTime")
private LocalDateTime dateTime;

private String status;

private String name;

@JsonProperty("qrCode")
private String qrCode;

@JsonProperty("createdAt")
private LocalDateTime createdAt;

@JsonProperty("creditsConsumed")
private Integer creditsConsumed;
}

Tests

WebhookControllerTest.java

package com.example.moncreneau.controller;

import com.example.moncreneau.service.WebhookService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(WebhookController.class)
class WebhookControllerTest {

@Autowired
private MockMvc mockMvc;

@MockBean
private WebhookService webhookService;

@Test
void shouldAcceptValidWebhook() throws Exception {
when(webhookService.verifySignature(anyString(), anyString()))
.thenReturn(true);

String payload = """{
"id": "evt_123",
"type": "appointment.created",
"data": { "object": { "id": 1 } }
}""";

mockMvc.perform(post("/webhooks/moncreneau")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Moncreneau-Signature", "sha256=valid_signature")
.content(payload))
.andExpect(status().isOk());
}

@Test
void shouldRejectInvalidSignature() throws Exception {
when(webhookService.verifySignature(anyString(), anyString()))
.thenReturn(false);

mockMvc.perform(post("/webhooks/moncreneau")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Moncreneau-Signature", "sha256=invalid_signature")
.content("{}"))
.andExpect(status().isUnauthorized());
}
}

Démarrage

# 1. Cloner le projet
git clone https://github.com/your-org/moncreneau-spring-boot.git
cd moncreneau-spring-boot

# 2. Configurer les variables d'environnement
export MONCRENEAU_API_KEY=YOUR_API_KEY
export MONCRENEAU_WEBHOOK_SECRET=your_webhook_secret

# 3. Compiler et lancer
./mvnw spring-boot:run

# Ou avec Docker
docker build -t moncreneau-spring-boot .
docker run -p 8080:8080 \
-e MONCRENEAU_API_KEY=YOUR_API_KEY \
-e MONCRENEAU_WEBHOOK_SECRET=your_secret \
moncreneau-spring-boot

Tests

# Lancer tous les tests
./mvnw test

# Avec coverage
./mvnw test jacoco:report

Ressources