API REST Spring Boot servant de relais entre un client et un fournisseur LLM (Ollama / OpenAI).
Le build du JAR se fait en local (là où le réseau est disponible), Docker ne fait qu'empaqueter le résultat — aucun téléchargement dans le container.
# 1. Compiler le JAR (nécessite Java 21)
./gradlew bootJar
# 2. Lancer l'API + Ollama
docker-compose up --build# Ollama doit tourner sur localhost:11434
./gradlew bootRun# Envoyer un prompt
curl -X POST http://localhost:8080/api/v1/ask \
-H "Content-Type: application/json" \
-d '{"prompt": "Explique le design pattern Strategy en Java"}'
# Consulter l'historique
curl http://localhost:8080/api/v1/history?limit=5
# Vérifier la santé de l'API
curl http://localhost:8080/api/v1/healthCe projet illustre les pratiques fondamentales du développement backend avec Spring Boot. Chaque section explique pourquoi une décision technique a été prise, pas seulement comment.
Client HTTP
│
▼
Controller ← reçoit la requête HTTP, valide, délègue
│
▼
Service ← contient la logique métier
│
▼
LlmClient ← communique avec le fournisseur externe
│
▼
Ollama / OpenAI
Pourquoi cette séparation ?
Chaque couche a une seule responsabilité (principe SRP — Single Responsibility Principle).
- Le Controller ne sait pas comment fonctionne Ollama.
- Le Service ne sait pas que les données arrivent via HTTP.
- Le Client ne connaît pas la logique métier.
Cela rend chaque classe plus facile à comprendre, tester et modifier indépendamment.
public interface LlmClient {
String ask(String prompt, String model, double temperature);
boolean isAvailable();
String getProviderName();
}OllamaClient et OpenAiClient implémentent cette interface.
Pourquoi une interface ?
Sans interface :
// Le service est couplé à Ollama → impossible de changer de provider
private OllamaClient ollamaClient;Avec interface :
// Le service dépend d'une abstraction → on peut injecter Ollama OU OpenAI
private LlmClient llmClient;C'est le principe de substitution de Liskov et le principe d'inversion de dépendances (D dans SOLID) : dépendre d'abstractions, pas d'implémentations concrètes.
Pour changer de provider, il suffit de modifier application.yml :
llm:
provider: openai # ou ollamaAucune modification du code Java nécessaire.
@ConfigurationProperties(prefix = "llm")
public class LlmProperties {
private String provider = "ollama";
private String baseUrl = "http://localhost:11434";
private String defaultModel = "llama3";
// ...
}Pourquoi ne pas mettre ces valeurs en dur dans le code ?
Le code ne doit pas changer selon l'environnement. Les valeurs de configuration doivent vivre dans des fichiers de config ou des variables d'environnement, pas dans le code source.
@ConfigurationProperties mappe automatiquement application.yml vers un objet Java typé. Les variables d'environnement surchargent les valeurs du fichier :
LLM_BASE_URL=http://ollama:11434 → llm.base-url → LlmProperties.baseUrl
Cela permet de déployer le même JAR en dev, staging et production avec des configs différentes.
@Configuration
public class LlmConfig {
@Bean
public LlmClient llmClient(LlmProperties props) {
return switch (props.getProvider().toLowerCase()) {
case "openai" -> new OpenAiClient(props);
default -> new OllamaClient(props);
};
}
}Spring gère un conteneur de beans. Quand AskService déclare LlmClient dans son constructeur, Spring lui injecte automatiquement le bon objet (Ollama ou OpenAI selon la config).
Inversion of Control (IoC) : ce n'est pas le code qui crée ses dépendances, c'est le framework qui les fournit. Cela découple les composants et facilite les tests (on peut injecter un mock).
public record AskRequest(
@NotBlank(message = "Le prompt ne peut pas être vide") String prompt,
String model,
@DecimalMin("0.0") @DecimalMax("2.0") Double temperature
) {}@PostMapping("/ask")
public ResponseEntity<AskResponse> ask(@Valid @RequestBody AskRequest request) { ... }@Valid déclenche la validation automatique avant d'entrer dans la méthode. Si elle échoue, Spring lève une MethodArgumentNotValidException — capturée par le GlobalExceptionHandler.
Pourquoi valider ?
Ne jamais faire confiance aux données entrantes. Valider à la frontière du système (entrée HTTP) évite de propager des données invalides dans la logique métier ou vers le LLM.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(...) { ... } // 400
@ExceptionHandler(LlmUnavailableException.class)
public ResponseEntity<ErrorResponse> handleLlmUnavailable(...) { ... } // 503
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(...) { ... } // 500
}Sans ce handler, Spring retournerait la stacktrace Java brute au client — c'est illisible et dangereux (fuite d'informations internes).
@RestControllerAdvice intercepte toutes les exceptions levées par les controllers et les transforme en réponses JSON structurées :
{
"status": 400,
"error": "Bad Request",
"message": "prompt: Le prompt ne peut pas être vide",
"timestamp": "2024-03-15T10:30:00Z"
}var response = restClient.post()
.uri("/api/generate")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(GenerateResponse.class);RestClient (Spring Boot 3.2+) remplace RestTemplate avec une API fluente (chaînage de méthodes). Jackson sérialise/désérialise automatiquement les objets Java en JSON.
Quand le serveur distant est injoignable, RestClient lève une ResourceAccessException. On la rattrape pour lancer une LlmUnavailableException → le GlobalExceptionHandler retourne un 503.
public record AskResponse(
String id,
String prompt,
String response,
String model,
long durationMs,
Instant timestamp
) {}Un record est une classe immuable avec constructeur, getters, equals, hashCode et toString générés automatiquement. Idéal pour les objets de transport de données (DTOs) qui ne changent pas après leur création.
private final Deque<Interaction> history = new ConcurrentLinkedDeque<>();Une API web reçoit des requêtes simultanées de plusieurs clients. Si deux requêtes écrivent en même temps dans une ArrayList, des données peuvent se corrompre (race condition).
ConcurrentLinkedDeque est une structure de données thread-safe : plusieurs threads peuvent lire et écrire simultanément sans corruption. C'est la bonne structure pour un historique partagé en mémoire.
Dockerfile — build multi-stage :
# Étape 1 : compiler le projet
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY . .
RUN ./gradlew bootJar --no-daemon
# Étape 2 : image finale légère (sans le JDK)
FROM eclipse-temurin:21-jre-alpine
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]Le build multi-stage produit une image finale petite (JRE seulement, sans les sources ni les outils de build).
Docker Compose — orchestration locale :
services:
api:
build: .
environment:
- LLM_BASE_URL=http://ollama:11434 # résolution DNS interne Docker
depends_on:
- ollama
ollama:
image: ollama/ollamadocker-compose up démarre les deux services ensemble. Le nom ollama dans LLM_BASE_URL est résolu automatiquement par le réseau interne Docker — pas besoin d'adresse IP.
src/main/java/com/razamwende/springbootllmrelay/
├── config/
│ ├── LlmProperties.java Mapping application.yml → objet Java
│ └── LlmConfig.java Sélection du client LLM (factory bean)
├── model/
│ ├── AskRequest.java DTO entrant (validé)
│ ├── AskResponse.java DTO sortant
│ └── Interaction.java Entrée historique (stocké en mémoire)
├── client/
│ ├── LlmClient.java Interface (port de sortie)
│ ├── OllamaClient.java Adaptateur Ollama
│ └── OpenAiClient.java Adaptateur OpenAI
├── service/
│ └── AskService.java Logique métier + gestion de l'historique
├── controller/
│ └── AskController.java Endpoints REST
└── exception/
├── LlmUnavailableException.java
└── GlobalExceptionHandler.java
| Variable | Défaut | Description |
|---|---|---|
LLM_PROVIDER |
ollama |
Provider LLM (ollama ou openai) |
LLM_BASE_URL |
http://localhost:11434 |
URL du provider |
LLM_DEFAULT_MODEL |
llama3 |
Modèle utilisé si non précisé dans la requête |
OPENAI_API_KEY |
(vide) | Clé API OpenAI (requis si provider = openai) |
| Méthode | URL | Description |
|---|---|---|
POST |
/api/v1/ask |
Envoie un prompt au LLM |
GET |
/api/v1/history?limit=10 |
Dernières interactions (max 50) |
GET |
/api/v1/health |
État de l'API et du provider LLM |
| Code | Cas |
|---|---|
400 |
Prompt vide ou température hors de [0.0, 2.0] |
503 |
Provider LLM injoignable |
500 |
Erreur interne |