Spring Boot Integration
Complete example of MONCRENEAU API integration with Spring Boot 3.x.
Project Structure
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
Spring Configuration
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");
// Send confirmation email
// Send SMS
// Update local database
}
private void handleAppointmentUpdated(Map<String, Object> eventData) {
log.info("Handling appointment.updated");
// Notify user of changes
}
private void handleAppointmentCancelled(Map<String, Object> eventData) {
log.info("Handling appointment.cancelled");
// Send cancellation email
// Free resources
}
private void handleAppointmentValidated(Map<String, Object> eventData) {
log.info("Handling appointment.validated");
// Confirm to staff
}
private void handleAppointmentCompleted(Map<String, Object> eventData) {
log.info("Handling appointment.completed");
// Send satisfaction survey
}
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) {
// Verify signature
if (!webhookService.verifySignature(payload, signature)) {
log.warn("Invalid webhook signature");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Invalid signature");
}
try {
// Parse 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");
// Process event asynchronously
// In a real project, use @Async or a 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());
}
}
Getting Started
# 1. Clone the project
git clone https://github.com/your-org/moncreneau-spring-boot.git
cd moncreneau-spring-boot
# 2. Configure environment variables
export MONCRENEAU_API_KEY=your_api_key
export MONCRENEAU_WEBHOOK_SECRET=your_webhook_secret
# 3. Build and run
./mvnw spring-boot:run
# Or with Docker
docker build -t moncreneau-spring-boot .
docker run -p 8080:8080 -e MONCRENEAU_API_KEY=your_key -e MONCRENEAU_WEBHOOK_SECRET=your_secret moncreneau-spring-boot
Testing
# Run all tests
./mvnw test
# With coverage
./mvnw test jacoco:report