diff --git a/.env.example b/.env.example
index 8ca0b1d..bd0ec26 100644
--- a/.env.example
+++ b/.env.example
@@ -38,3 +38,15 @@ LLM_MODEL=gemma4:26b
# 1min.ai (si LLM_PROVIDER=onemin)
ONEMIN_API_KEY=
ONEMIN_MODEL=gpt-4o-mini
+
+# --- Mises a jour automatiques (Watchtower) ------------------------------
+# Watchtower verifie les nouvelles versions de core/brain/web et permet
+# le declenchement manuel via l'UI (bouton "Mettre a jour"). Postgres et
+# MinIO sont exclus volontairement.
+#
+# Activer : COMPOSE_PROFILES=autoupdate + WATCHTOWER_TOKEN non vide.
+# COMPOSE_PROFILES=autoupdate
+# WATCHTOWER_TOKEN=change-me-use-openssl-rand-hex-32
+# WATCHTOWER_MONITOR_ONLY=false # true = detecter sans appliquer
+# WATCHTOWER_SCHEDULE=0 0 4 * * *
+# TZ=Europe/Paris
diff --git a/brain/app/main.py b/brain/app/main.py
index 24f22f5..10e88b0 100644
--- a/brain/app/main.py
+++ b/brain/app/main.py
@@ -40,7 +40,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
- version="0.6.5",
+ version="0.6.6",
)
diff --git a/core/pom.xml b/core/pom.xml
index 750ab69..3ed2dd1 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -14,7 +14,7 @@
com.loremindloremind-core
- 0.6.5
+ 0.6.6LoreMind CoreBackend Core - Architecture Hexagonale
diff --git a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java
index b6d6ac3..927e23d 100644
--- a/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java
+++ b/core/src/main/java/com/loremind/application/campaigncontext/SceneService.java
@@ -98,7 +98,7 @@ public class SceneService {
.collect(Collectors.toSet());
for (SceneBranch b : branches) {
- String target = b.getTargetSceneId();
+ String target = b.targetSceneId();
if (target == null || target.isBlank()) {
throw new IllegalArgumentException("Une branche doit avoir une scène de destination");
}
diff --git a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemContextBuilder.java b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemContextBuilder.java
index ba959f3..4b05b1f 100644
--- a/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemContextBuilder.java
+++ b/core/src/main/java/com/loremind/application/gamesystemcontext/GameSystemContextBuilder.java
@@ -45,11 +45,7 @@ public class GameSystemContextBuilder {
private GameSystemContext build(GameSystem gs, GenerationIntent intent) {
Map allSections = parseH2Sections(gs.getRulesMarkdown());
Map filtered = filterByIntent(allSections, intent);
- return GameSystemContext.builder()
- .systemName(gs.getName())
- .systemDescription(gs.getDescription())
- .sections(filtered)
- .build();
+ return new GameSystemContext(gs.getName(), gs.getDescription(), filtered);
}
/**
diff --git a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java
index 8e919d4..28ff306 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/CampaignStructuralContextBuilder.java
@@ -79,12 +79,11 @@ public class CampaignStructuralContextBuilder {
.map(this::toCharacterSummary)
.collect(Collectors.toList());
- return CampaignStructuralContext.builder()
- .campaignName(campaign.getName())
- .campaignDescription(campaign.getDescription())
- .arcs(arcs)
- .characters(characters)
- .build();
+ return new CampaignStructuralContext(
+ campaign.getName(),
+ campaign.getDescription(),
+ arcs,
+ characters);
}
/**
@@ -93,10 +92,7 @@ public class CampaignStructuralContextBuilder {
* sans injecter toute sa fiche.
*/
private CharacterSummary toCharacterSummary(Character c) {
- return CharacterSummary.builder()
- .name(c.getName())
- .snippet(extractSnippet(c.getMarkdownContent()))
- .build();
+ return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
}
private static String extractSnippet(String markdown) {
@@ -115,12 +111,11 @@ public class CampaignStructuralContextBuilder {
.sorted(Comparator.comparingInt(Chapter::getOrder))
.map(this::toChapterSummary)
.collect(Collectors.toList());
- return ArcSummary.builder()
- .name(arc.getName())
- .description(arc.getDescription())
- .illustrationCount(countImages(arc.getIllustrationImageIds()))
- .chapters(chapters)
- .build();
+ return new ArcSummary(
+ arc.getName(),
+ arc.getDescription(),
+ countImages(arc.getIllustrationImageIds()),
+ chapters);
}
private ChapterSummary toChapterSummary(Chapter chapter) {
@@ -137,32 +132,28 @@ public class CampaignStructuralContextBuilder {
.map(s -> toSceneSummary(s, nameById))
.collect(Collectors.toList());
- return ChapterSummary.builder()
- .name(chapter.getName())
- .description(chapter.getDescription())
- .illustrationCount(countImages(chapter.getIllustrationImageIds()))
- .scenes(summaries)
- .build();
+ return new ChapterSummary(
+ chapter.getName(),
+ chapter.getDescription(),
+ countImages(chapter.getIllustrationImageIds()),
+ summaries);
}
private SceneSummary toSceneSummary(Scene scene, Map nameById) {
List hints = scene.getBranches() == null
? List.of()
: scene.getBranches().stream()
- .map(b -> BranchHint.builder()
- .label(b.getLabel())
- .targetSceneName(nameById.getOrDefault(
- b.getTargetSceneId(), "(scène inconnue)"))
- .condition(b.getCondition())
- .build())
+ .map(b -> new BranchHint(
+ b.label(),
+ nameById.getOrDefault(b.targetSceneId(), "(scène inconnue)"),
+ b.condition()))
.collect(Collectors.toList());
- return SceneSummary.builder()
- .name(scene.getName())
- .description(scene.getDescription())
- .illustrationCount(countImages(scene.getIllustrationImageIds()))
- .branches(hints)
- .build();
+ return new SceneSummary(
+ scene.getName(),
+ scene.getDescription(),
+ countImages(scene.getIllustrationImageIds()),
+ hints);
}
/** Helper defensif : compte les illustrations attachees (null-safe). */
diff --git a/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java
index 71c334b..a676055 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/GeneratePageValuesUseCase.java
@@ -67,16 +67,15 @@ public class GeneratePageValuesUseCase {
requireNonEmptyFields(template);
- GenerationContext context = GenerationContext.builder()
- .loreName(lore.getName())
- .loreDescription(lore.getDescription())
- .folderName(folder.getName())
- .templateName(template.getName())
- // Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
- // necessitent un workflow different (pas de generation LLM texte).
- .templateFields(template.textFieldNames())
- .pageTitle(page.getTitle())
- .build();
+ // Seuls les champs TEXT sont envoyes a l'IA : les champs IMAGE
+ // necessitent un workflow different (pas de generation LLM texte).
+ GenerationContext context = new GenerationContext(
+ lore.getName(),
+ lore.getDescription(),
+ folder.getName(),
+ template.getName(),
+ template.textFieldNames(),
+ page.getTitle());
GenerationResult result = aiProvider.generatePage(context);
return result.values();
diff --git a/core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java
index ee665a1..32d58ad 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/LoreStructuralContextBuilder.java
@@ -82,12 +82,11 @@ public class LoreStructuralContextBuilder {
Map pageTitleById = pages.stream()
.collect(Collectors.toMap(Page::getId, Page::getTitle, (a, b) -> a));
- return LoreStructuralContext.builder()
- .loreName(lore.getName())
- .loreDescription(lore.getDescription())
- .folders(buildFoldersMap(nodes, pages, templateNameById, pageTitleById))
- .tags(extractUniqueTags(pages))
- .build();
+ return new LoreStructuralContext(
+ lore.getName(),
+ lore.getDescription(),
+ buildFoldersMap(nodes, pages, templateNameById, pageTitleById),
+ extractUniqueTags(pages));
}
private Map> buildFoldersMap(
@@ -118,13 +117,12 @@ public class LoreStructuralContextBuilder {
Page page,
Map templateNameById,
Map pageTitleById) {
- return PageSummary.builder()
- .title(page.getTitle())
- .templateName(templateNameById.getOrDefault(page.getTemplateId(), "?"))
- .values(truncatedValues(page.getValues()))
- .tags(page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList())
- .relatedPageTitles(resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById))
- .build();
+ return new PageSummary(
+ page.getTitle(),
+ templateNameById.getOrDefault(page.getTemplateId(), "?"),
+ truncatedValues(page.getValues()),
+ page.getTags() != null ? List.copyOf(page.getTags()) : Collections.emptyList(),
+ resolveRelatedTitles(page.getRelatedPageIds(), pageTitleById));
}
/**
diff --git a/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java
index 149e6f5..54256a5 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/NarrativeEntityContextBuilder.java
@@ -91,11 +91,7 @@ public class NarrativeEntityContextBuilder {
putField(fields, "rewards", a.getRewards());
putField(fields, "resolution", a.getResolution());
putField(fields, "gmNotes", a.getGmNotes());
- return NarrativeEntityContext.builder()
- .entityType("arc")
- .title(a.getName())
- .fields(fields)
- .build();
+ return new NarrativeEntityContext("arc", a.getName(), fields);
}
private NarrativeEntityContext fromChapter(Chapter c) {
@@ -104,11 +100,7 @@ public class NarrativeEntityContextBuilder {
putField(fields, "playerObjectives", c.getPlayerObjectives());
putField(fields, "narrativeStakes", c.getNarrativeStakes());
putField(fields, "gmNotes", c.getGmNotes());
- return NarrativeEntityContext.builder()
- .entityType("chapter")
- .title(c.getName())
- .fields(fields)
- .build();
+ return new NarrativeEntityContext("chapter", c.getName(), fields);
}
private NarrativeEntityContext fromScene(Scene s) {
@@ -122,21 +114,13 @@ public class NarrativeEntityContextBuilder {
putField(fields, "combatDifficulty", s.getCombatDifficulty());
putField(fields, "enemies", s.getEnemies());
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
- return NarrativeEntityContext.builder()
- .entityType("scene")
- .title(s.getName())
- .fields(fields)
- .build();
+ return new NarrativeEntityContext("scene", s.getName(), fields);
}
private NarrativeEntityContext fromCharacter(Character c) {
Map fields = new LinkedHashMap<>();
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
- return NarrativeEntityContext.builder()
- .entityType("character")
- .title(c.getName())
- .fields(fields)
- .build();
+ return new NarrativeEntityContext("character", c.getName(), fields);
}
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
diff --git a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java
index 3668e30..7c6cfb2 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/StreamChatForLoreUseCase.java
@@ -107,11 +107,6 @@ public class StreamChatForLoreUseCase {
? page.getValues()
: Collections.emptyMap();
- return PageContext.builder()
- .title(page.getTitle())
- .templateName(templateName)
- .templateFields(templateFields)
- .values(values)
- .build();
+ return new PageContext(page.getTitle(), templateName, templateFields, values);
}
}
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java b/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java
index c587d21..186751c 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/SceneBranch.java
@@ -1,31 +1,25 @@
package com.loremind.domain.campaigncontext;
-import lombok.Builder;
-import lombok.Value;
-import lombok.extern.jackson.Jacksonized;
-
/**
* Value Object représentant une "sortie" narrative depuis une Scene.
* Décrit un choix offert aux joueurs et la scène de destination associée.
*
- * Immuable (@Value) : pour "modifier" une branche on la remplace.
- * @Jacksonized : permet à Jackson (sérialisation JSON via le converter JPA)
- * de reconstruire l'objet en passant par le builder malgré l'absence de setters.
+ * Record Java : immuable par construction, sans aucune dépendance technique
+ * (pas de Lombok, pas de Jackson). Jackson 2.12+ sait sérialiser/désérialiser
+ * les records nativement via le constructeur canonique — c'est ce dont
+ * dépend le SceneBranchListJsonConverter pour le stockage JSONB.
*
* Règle métier : targetSceneId DOIT pointer vers une Scene du MÊME Chapter
* (validation portée par SceneService).
+ *
+ * @param label Libellé du choix (ex: "Si les joueurs attaquent le garde").
+ * @param targetSceneId Id de la Scene de destination, intra-chapitre uniquement.
+ * @param condition Notes MJ privées sur la condition de déclenchement (optionnel).
*/
-@Value
-@Builder
-@Jacksonized
-public class SceneBranch {
+public record SceneBranch(String label, String targetSceneId, String condition) {
- /** Libellé du choix (ex: "Si les joueurs attaquent le garde"). */
- String label;
-
- /** Id de la Scene de destination, intra-chapitre uniquement. */
- String targetSceneId;
-
- /** Notes MJ privées sur la condition de déclenchement (optionnel). */
- String condition;
+ /** Raccourci pour construire une branche sans condition (cas le plus courant). */
+ public static SceneBranch of(String label, String targetSceneId) {
+ return new SceneBranch(label, targetSceneId, null);
+ }
}
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java
index f6fbbe7..02e02fe 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/CampaignStructuralContext.java
@@ -1,9 +1,5 @@
package com.loremind.domain.generationcontext;
-import lombok.Builder;
-import lombok.Singular;
-import lombok.Value;
-
import java.util.List;
/**
@@ -22,16 +18,16 @@ import java.util.List;
*
* La liste `arcs` préserve l'ordre narratif (tri sur `order` ascendant
* fait par le use case côté application layer).
+ *
+ * Record Java : pur domaine, aucune dépendance technique.
+ *
+ * @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun.
*/
-@Value
-@Builder
-public class CampaignStructuralContext {
-
- String campaignName;
- String campaignDescription;
- @Singular List arcs;
- /** Personnages joueurs (PJ) de la campagne. Vide si aucun. */
- @Singular List characters;
+public record CampaignStructuralContext(
+ String campaignName,
+ String campaignDescription,
+ List arcs,
+ List characters) {
/**
* Résumé d'un PJ : nom + snippet court du markdown.
@@ -40,53 +36,44 @@ public class CampaignStructuralContext {
* La fiche complète n'est injectée que si le PJ est l'entité focus
* (via NarrativeEntityContext, entity_type="character").
*/
- @Value
- @Builder
- public static class CharacterSummary {
- String name;
- String snippet;
+ public record CharacterSummary(String name, String snippet) {
}
- /** Résumé d'un arc : nom + description courte + ses chapitres. */
- @Value
- @Builder
- public static class ArcSummary {
- String name;
- String description;
- /** Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). */
- int illustrationCount;
- @Singular List chapters;
+ /**
+ * Résumé d'un arc : nom + description courte + ses chapitres.
+ *
+ * @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
+ */
+ public record ArcSummary(
+ String name,
+ String description,
+ int illustrationCount,
+ List chapters) {
}
/** Résumé d'un chapitre : nom + description courte + ses scènes. */
- @Value
- @Builder
- public static class ChapterSummary {
- String name;
- String description;
- int illustrationCount;
- @Singular List scenes;
+ public record ChapterSummary(
+ String name,
+ String description,
+ int illustrationCount,
+ List scenes) {
}
/** Résumé d'une scène : nom + description courte + branches narratives. */
- @Value
- @Builder
- public static class SceneSummary {
- String name;
- String description;
- int illustrationCount;
- @Singular List branches;
+ public record SceneSummary(
+ String name,
+ String description,
+ int illustrationCount,
+ List branches) {
}
- /** Indice d'une branche narrative vers une autre scène du même chapitre. */
- @Value
- @Builder
- public static class BranchHint {
- /** Libellé du choix joueur (ex: "Si les joueurs attaquent le garde"). */
- String label;
- /** Nom de la scène cible (résolu depuis targetSceneId côté builder). */
- String targetSceneName;
- /** Condition MJ privée (optionnel). */
- String condition;
+ /**
+ * Indice d'une branche narrative vers une autre scène du même chapitre.
+ *
+ * @param label Libellé du choix joueur (ex: "Si les joueurs attaquent le garde").
+ * @param targetSceneName Nom de la scène cible (résolu depuis targetSceneId côté builder).
+ * @param condition Condition MJ privée (optionnel).
+ */
+ public record BranchHint(String label, String targetSceneName, String condition) {
}
}
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java b/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java
index ce002ab..48f5d1a 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/ChatRequest.java
@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
-import lombok.Builder;
-import lombok.Value;
-
import java.util.List;
/**
@@ -21,28 +18,74 @@ import java.util.List;
* Un chat Lore ne reçoit JAMAIS de campaignContext : un Lore ne voit pas
* ses campagnes (asymétrie métier : la campagne est l'emprunteur du Lore,
* pas l'inverse).
+ *
+ * Record Java : pur domaine. Builder manuel fourni en raison des 6 champs
+ * dont 5 sont nullables — l'API fluide reste plus lisible aux call sites
+ * qu'un constructeur à 6 paramètres souvent à null.
+ *
+ * @param loreContext Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore.
+ * @param pageContext Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement).
+ * @param campaignContext Optionnel : carte narrative d'une Campagne (chat Campagne uniquement).
+ * @param narrativeEntity Optionnel : entité narrative en cours d'édition (arc/chapter/scene).
+ * @param gameSystemContext Optionnel : règles du système de JDR de la campagne (filtrées par intent).
+ * Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
*/
-@Value
-@Builder
-public class ChatRequest {
+public record ChatRequest(
+ List messages,
+ LoreStructuralContext loreContext,
+ PageContext pageContext,
+ CampaignStructuralContext campaignContext,
+ NarrativeEntityContext narrativeEntity,
+ GameSystemContext gameSystemContext) {
- List messages;
+ public static Builder builder() {
+ return new Builder();
+ }
- /** Optionnel : carte structurelle du Lore. Null si campagne non liée à un Lore. */
- LoreStructuralContext loreContext;
+ /** Builder fluide : permet d'omettre les contextes non pertinents. */
+ public static final class Builder {
+ private List messages;
+ private LoreStructuralContext loreContext;
+ private PageContext pageContext;
+ private CampaignStructuralContext campaignContext;
+ private NarrativeEntityContext narrativeEntity;
+ private GameSystemContext gameSystemContext;
- /** Optionnel : contexte d'une page précise en cours d'édition (chat Lore uniquement). */
- PageContext pageContext;
+ private Builder() {}
- /** Optionnel : carte narrative d'une Campagne (chat Campagne uniquement). */
- CampaignStructuralContext campaignContext;
+ public Builder messages(List messages) {
+ this.messages = messages;
+ return this;
+ }
- /** Optionnel : entité narrative en cours d'édition (arc/chapter/scene). */
- NarrativeEntityContext narrativeEntity;
+ public Builder loreContext(LoreStructuralContext loreContext) {
+ this.loreContext = loreContext;
+ return this;
+ }
- /**
- * Optionnel : règles du système de JDR de la campagne (filtrées par intent).
- * Null si la campagne n'a pas de GameSystem associé. Campagne uniquement au MVP.
- */
- GameSystemContext gameSystemContext;
+ public Builder pageContext(PageContext pageContext) {
+ this.pageContext = pageContext;
+ return this;
+ }
+
+ public Builder campaignContext(CampaignStructuralContext campaignContext) {
+ this.campaignContext = campaignContext;
+ return this;
+ }
+
+ public Builder narrativeEntity(NarrativeEntityContext narrativeEntity) {
+ this.narrativeEntity = narrativeEntity;
+ return this;
+ }
+
+ public Builder gameSystemContext(GameSystemContext gameSystemContext) {
+ this.gameSystemContext = gameSystemContext;
+ return this;
+ }
+
+ public ChatRequest build() {
+ return new ChatRequest(messages, loreContext, pageContext,
+ campaignContext, narrativeEntity, gameSystemContext);
+ }
+ }
}
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/GameSystemContext.java b/core/src/main/java/com/loremind/domain/generationcontext/GameSystemContext.java
index 8e66836..e27e518 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/GameSystemContext.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/GameSystemContext.java
@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
-import lombok.Builder;
-import lombok.Value;
-
import java.util.Map;
/**
@@ -11,20 +8,14 @@ import java.util.Map;
* Contient uniquement les sections pertinentes pour l'intent de génération
* en cours (sélection effectuée par GameSystemContextBuilder). Les sections
* sont indexées par leur titre H2 original (ex : "Combat", "Classes").
+ *
+ * @param systemName Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD").
+ * @param systemDescription Description courte du système (nullable).
+ * @param sections Sections de règles pertinentes, indexées par titre H2.
+ * Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
*/
-@Value
-@Builder
-public class GameSystemContext {
-
- /** Nom du système de JDR (ex : "Nimble", "D&D 5.1 SRD"). */
- String systemName;
-
- /** Description courte du système (nullable). */
- String systemDescription;
-
- /**
- * Sections de règles pertinentes, indexées par titre H2.
- * Vide si le GameSystem n'a aucune règle ou si aucune section ne matche l'intent.
- */
- Map sections;
+public record GameSystemContext(
+ String systemName,
+ String systemDescription,
+ Map sections) {
}
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/GenerationContext.java b/core/src/main/java/com/loremind/domain/generationcontext/GenerationContext.java
index 2f0e5ae..dc5b49e 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/GenerationContext.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/GenerationContext.java
@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
-import lombok.Builder;
-import lombok.Value;
-
import java.util.List;
/**
@@ -10,19 +7,16 @@ import java.util.List;
* pour remplir une Page à partir d'un Template.
*
* Équivalent Java du PageGenerationContext Python (brain/app/domain/models.py).
- * Entité pure du domaine : aucune dépendance technique.
- *
- * Immuable via @Value (Lombok) : pas de setters, tous les champs final.
- * C'est un DTO de domaine entrant dans le port AiProvider.
+ * Record Java : pur domaine, aucune dépendance technique.
+ *
+ * @param templateFields Champs à générer (clés attendues dans la réponse).
+ * @param folderName Nom du LoreNode parent (ex: "PNJ", "Lieux").
*/
-@Value
-@Builder
-public class GenerationContext {
-
- String loreName;
- String loreDescription;
- String folderName; // Nom du LoreNode parent (ex: "PNJ", "Lieux")
- String templateName;
- List templateFields; // Champs à générer (clés attendues dans la réponse)
- String pageTitle;
+public record GenerationContext(
+ String loreName,
+ String loreDescription,
+ String folderName,
+ String templateName,
+ List templateFields,
+ String pageTitle) {
}
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/LoreStructuralContext.java b/core/src/main/java/com/loremind/domain/generationcontext/LoreStructuralContext.java
index a404456..3603656 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/LoreStructuralContext.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/LoreStructuralContext.java
@@ -1,9 +1,5 @@
package com.loremind.domain.generationcontext;
-import lombok.Builder;
-import lombok.Singular;
-import lombok.Value;
-
import java.util.List;
import java.util.Map;
@@ -16,15 +12,14 @@ import java.util.Map;
*
* La map `folders` est indexée par nom de dossier et mappe vers la liste
* des pages qu'il contient (liste vide autorisée pour les dossiers vides).
+ *
+ * Record Java : pur domaine, aucune dépendance technique.
*/
-@Value
-@Builder
-public class LoreStructuralContext {
-
- String loreName;
- String loreDescription;
- Map> folders;
- @Singular List tags;
+public record LoreStructuralContext(
+ String loreName,
+ String loreDescription,
+ Map> folders,
+ List tags) {
/**
* Résumé projeté d'une page pour l'IA.
@@ -40,13 +35,11 @@ public class LoreStructuralContext {
* uniquement ce qui est partageable en narration — les secrets MJ
* restent confinés à leur page d'édition).
*/
- @Value
- @Builder
- public static class PageSummary {
- String title;
- String templateName;
- Map values;
- List tags;
- List relatedPageTitles;
+ public record PageSummary(
+ String title,
+ String templateName,
+ Map values,
+ List tags,
+ List relatedPageTitles) {
}
}
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/NarrativeEntityContext.java b/core/src/main/java/com/loremind/domain/generationcontext/NarrativeEntityContext.java
index e47ac76..f2000f6 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/NarrativeEntityContext.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/NarrativeEntityContext.java
@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
-import lombok.Builder;
-import lombok.Value;
-
import java.util.Map;
/**
@@ -17,13 +14,11 @@ import java.util.Map;
* `fields` associe le nom d'un champ (ex: "themes", "playerNarration")
* à sa valeur actuelle (chaîne vide si non renseigné). Utiliser une
* LinkedHashMap à la construction pour un prompt lisible (ordre préservé).
+ *
+ * @param entityType "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt.
*/
-@Value
-@Builder
-public class NarrativeEntityContext {
-
- /** "arc", "chapter" ou "scene" — utilisé pour libeller le bloc du prompt. */
- String entityType;
- String title;
- Map fields;
+public record NarrativeEntityContext(
+ String entityType,
+ String title,
+ Map fields) {
}
diff --git a/core/src/main/java/com/loremind/domain/generationcontext/PageContext.java b/core/src/main/java/com/loremind/domain/generationcontext/PageContext.java
index f0129ca..b5d9e6c 100644
--- a/core/src/main/java/com/loremind/domain/generationcontext/PageContext.java
+++ b/core/src/main/java/com/loremind/domain/generationcontext/PageContext.java
@@ -1,8 +1,5 @@
package com.loremind.domain.generationcontext;
-import lombok.Builder;
-import lombok.Value;
-
import java.util.List;
import java.util.Map;
@@ -14,14 +11,11 @@ import java.util.Map;
* à l'IA de focaliser ses suggestions sur les bons champs sans déborder
* sur d'autres pages/templates.
*
- * Object de valeur immuable, pur domaine — aucune dépendance technique.
+ * Record Java : immuable, pur domaine, aucune dépendance technique.
*/
-@Value
-@Builder
-public class PageContext {
-
- String title;
- String templateName;
- List templateFields;
- Map values;
+public record PageContext(
+ String title,
+ String templateName,
+ List templateFields,
+ Map values) {
}
diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainAiClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainAiClient.java
index 25cd1f2..9e4b6d9 100644
--- a/core/src/main/java/com/loremind/infrastructure/ai/BrainAiClient.java
+++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainAiClient.java
@@ -53,12 +53,12 @@ public class BrainAiClient implements AiProvider {
private BrainGeneratePageRequest toBrainRequest(GenerationContext context) {
return new BrainGeneratePageRequest(
- context.getLoreName(),
- context.getLoreDescription(),
- context.getFolderName(),
- context.getTemplateName(),
- context.getTemplateFields(),
- context.getPageTitle()
+ context.loreName(),
+ context.loreDescription(),
+ context.folderName(),
+ context.templateName(),
+ context.templateFields(),
+ context.pageTitle()
);
}
diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java
index 8d8781f..b6eeb3e 100644
--- a/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java
+++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainChatPayloadBuilder.java
@@ -38,35 +38,35 @@ public class BrainChatPayloadBuilder {
public Map build(ChatRequest request) {
Map root = new LinkedHashMap<>();
- root.put("messages", request.getMessages().stream()
+ root.put("messages", request.messages().stream()
.map(this::messageToMap)
.collect(Collectors.toList()));
- if (request.getLoreContext() != null) {
- root.put("lore_context", loreContextToMap(request.getLoreContext()));
+ if (request.loreContext() != null) {
+ root.put("lore_context", loreContextToMap(request.loreContext()));
}
- if (request.getPageContext() != null) {
- root.put("page_context", pageContextToMap(request.getPageContext()));
+ if (request.pageContext() != null) {
+ root.put("page_context", pageContextToMap(request.pageContext()));
}
- if (request.getCampaignContext() != null) {
- root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
+ if (request.campaignContext() != null) {
+ root.put("campaign_context", campaignContextToMap(request.campaignContext()));
}
- if (request.getNarrativeEntity() != null) {
- root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
+ if (request.narrativeEntity() != null) {
+ root.put("narrative_entity", narrativeEntityToMap(request.narrativeEntity()));
}
- if (request.getGameSystemContext() != null) {
- root.put("game_system_context", gameSystemContextToMap(request.getGameSystemContext()));
+ if (request.gameSystemContext() != null) {
+ root.put("game_system_context", gameSystemContextToMap(request.gameSystemContext()));
}
return root;
}
private Map gameSystemContextToMap(GameSystemContext gs) {
Map map = new LinkedHashMap<>();
- map.put("system_name", gs.getSystemName());
- if (gs.getSystemDescription() != null && !gs.getSystemDescription().isBlank()) {
- map.put("system_description", gs.getSystemDescription());
+ map.put("system_name", gs.systemName());
+ if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) {
+ map.put("system_description", gs.systemDescription());
}
- map.put("sections", gs.getSections() != null ? gs.getSections() : Map.of());
+ map.put("sections", gs.sections() != null ? gs.sections() : Map.of());
return map;
}
@@ -79,56 +79,56 @@ public class BrainChatPayloadBuilder {
private Map loreContextToMap(LoreStructuralContext ctx) {
Map map = new LinkedHashMap<>();
- map.put("lore_name", ctx.getLoreName());
- map.put("lore_description", ctx.getLoreDescription());
+ map.put("lore_name", ctx.loreName());
+ map.put("lore_description", ctx.loreDescription());
Map foldersMap = new LinkedHashMap<>();
- for (Map.Entry> e : ctx.getFolders().entrySet()) {
+ for (Map.Entry> e : ctx.folders().entrySet()) {
foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::pageSummaryToMap)
.collect(Collectors.toList()));
}
map.put("folders", foldersMap);
- map.put("tags", ctx.getTags());
+ map.put("tags", ctx.tags());
return map;
}
private Map pageSummaryToMap(PageSummary ps) {
Map map = new LinkedHashMap<>();
- map.put("title", ps.getTitle());
- map.put("template_name", ps.getTemplateName());
+ map.put("title", ps.title());
+ map.put("template_name", ps.templateName());
// values/tags/related_page_titles : omis si vides pour alléger le payload.
- if (ps.getValues() != null && !ps.getValues().isEmpty()) {
- map.put("values", ps.getValues());
+ if (ps.values() != null && !ps.values().isEmpty()) {
+ map.put("values", ps.values());
}
- if (ps.getTags() != null && !ps.getTags().isEmpty()) {
- map.put("tags", ps.getTags());
+ if (ps.tags() != null && !ps.tags().isEmpty()) {
+ map.put("tags", ps.tags());
}
- if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
- map.put("related_page_titles", ps.getRelatedPageTitles());
+ if (ps.relatedPageTitles() != null && !ps.relatedPageTitles().isEmpty()) {
+ map.put("related_page_titles", ps.relatedPageTitles());
}
return map;
}
private Map pageContextToMap(PageContext pc) {
Map map = new LinkedHashMap<>();
- map.put("title", pc.getTitle());
- map.put("template_name", pc.getTemplateName());
- map.put("template_fields", pc.getTemplateFields());
- map.put("values", pc.getValues());
+ map.put("title", pc.title());
+ map.put("template_name", pc.templateName());
+ map.put("template_fields", pc.templateFields());
+ map.put("values", pc.values());
return map;
}
private Map campaignContextToMap(CampaignStructuralContext ctx) {
Map map = new LinkedHashMap<>();
- map.put("campaign_name", ctx.getCampaignName());
- map.put("campaign_description", ctx.getCampaignDescription());
- map.put("arcs", ctx.getArcs().stream()
+ map.put("campaign_name", ctx.campaignName());
+ map.put("campaign_description", ctx.campaignDescription());
+ map.put("arcs", ctx.arcs().stream()
.map(this::arcSummaryToMap)
.collect(Collectors.toList()));
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
- if (ctx.getCharacters() != null && !ctx.getCharacters().isEmpty()) {
- map.put("characters", ctx.getCharacters().stream()
+ if (ctx.characters() != null && !ctx.characters().isEmpty()) {
+ map.put("characters", ctx.characters().stream()
.map(this::characterSummaryToMap)
.collect(Collectors.toList()));
}
@@ -137,9 +137,9 @@ public class BrainChatPayloadBuilder {
private Map characterSummaryToMap(CharacterSummary c) {
Map map = new LinkedHashMap<>();
- map.put("name", c.getName());
- if (c.getSnippet() != null && !c.getSnippet().isBlank()) {
- map.put("snippet", c.getSnippet());
+ map.put("name", c.name());
+ if (c.snippet() != null && !c.snippet().isBlank()) {
+ map.put("snippet", c.snippet());
}
return map;
}
@@ -167,10 +167,10 @@ public class BrainChatPayloadBuilder {
private Map arcSummaryToMap(ArcSummary a) {
return structuralSummaryToMap(
a,
- ArcSummary::getName,
- ArcSummary::getDescription,
- ArcSummary::getIllustrationCount,
- (map, arc) -> map.put("chapters", arc.getChapters().stream()
+ ArcSummary::name,
+ ArcSummary::description,
+ ArcSummary::illustrationCount,
+ (map, arc) -> map.put("chapters", arc.chapters().stream()
.map(this::chapterSummaryToMap)
.collect(Collectors.toList())));
}
@@ -178,10 +178,10 @@ public class BrainChatPayloadBuilder {
private Map chapterSummaryToMap(ChapterSummary c) {
return structuralSummaryToMap(
c,
- ChapterSummary::getName,
- ChapterSummary::getDescription,
- ChapterSummary::getIllustrationCount,
- (map, chapter) -> map.put("scenes", chapter.getScenes().stream()
+ ChapterSummary::name,
+ ChapterSummary::description,
+ ChapterSummary::illustrationCount,
+ (map, chapter) -> map.put("scenes", chapter.scenes().stream()
.map(this::sceneSummaryToMap)
.collect(Collectors.toList())));
}
@@ -189,13 +189,13 @@ public class BrainChatPayloadBuilder {
private Map sceneSummaryToMap(SceneSummary s) {
return structuralSummaryToMap(
s,
- SceneSummary::getName,
- SceneSummary::getDescription,
- SceneSummary::getIllustrationCount,
+ SceneSummary::name,
+ SceneSummary::description,
+ SceneSummary::illustrationCount,
(map, scene) -> {
// Branches narratives : omises si absentes (scènes linéaires classiques).
- if (s.getBranches() != null && !s.getBranches().isEmpty()) {
- map.put("branches", s.getBranches().stream()
+ if (s.branches() != null && !s.branches().isEmpty()) {
+ map.put("branches", s.branches().stream()
.map(this::branchHintToMap)
.collect(Collectors.toList()));
}
@@ -204,19 +204,19 @@ public class BrainChatPayloadBuilder {
private Map branchHintToMap(BranchHint b) {
Map map = new LinkedHashMap<>();
- map.put("label", b.getLabel());
- map.put("target_scene_name", b.getTargetSceneName());
- if (b.getCondition() != null && !b.getCondition().isBlank()) {
- map.put("condition", b.getCondition());
+ map.put("label", b.label());
+ map.put("target_scene_name", b.targetSceneName());
+ if (b.condition() != null && !b.condition().isBlank()) {
+ map.put("condition", b.condition());
}
return map;
}
private Map narrativeEntityToMap(NarrativeEntityContext ne) {
Map map = new LinkedHashMap<>();
- map.put("entity_type", ne.getEntityType());
- map.put("title", ne.getTitle());
- map.put("fields", ne.getFields());
+ map.put("entity_type", ne.entityType());
+ map.put("title", ne.title());
+ map.put("fields", ne.fields());
return map;
}
}
diff --git a/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java b/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java
new file mode 100644
index 0000000..a32e3a3
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/updates/UpdateCheckService.java
@@ -0,0 +1,283 @@
+package com.loremind.infrastructure.updates;
+
+import jakarta.annotation.PostConstruct;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.HttpClientErrorException;
+import org.springframework.web.client.RestTemplate;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Detection des mises a jour disponibles + declenchement via Watchtower.
+ *
+ * Strategie :
+ * - Au demarrage, on interroge le registry pour le digest courant de chaque
+ * image suivie ({@code update-check.images}). On stocke ces digests comme
+ * "baseline" (= ce que le conteneur en cours d'execution est cense faire
+ * tourner, puisque le `docker compose pull` precede toujours `up -d`).
+ * - {@link #check()} re-interroge le registry et compare. Si un digest a
+ * change, une mise a jour est disponible.
+ * - {@link #apply()} POST sur /v1/update de Watchtower (qui doit etre lance
+ * avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
+ *
+ * Apres un apply reussi, Watchtower redemarre core => ce service est
+ * re-instancie => baseline re-aligne sur le registry => check renvoie
+ * "pas de MAJ" (etat coherent).
+ *
+ * La feature est desactivee silencieusement si {@code WATCHTOWER_TOKEN}
+ * n'est pas defini : check/apply renvoient des reponses neutres et l'UI
+ * masque le badge / bouton.
+ */
+@Service
+public class UpdateCheckService {
+
+ private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
+
+ private static final List MANIFEST_ACCEPT = List.of(
+ MediaType.parseMediaType("application/vnd.docker.distribution.manifest.v2+json"),
+ MediaType.parseMediaType("application/vnd.docker.distribution.manifest.list.v2+json"),
+ MediaType.parseMediaType("application/vnd.oci.image.manifest.v1+json"),
+ MediaType.parseMediaType("application/vnd.oci.image.index.v1+json")
+ );
+
+ private final RestTemplate http;
+ private final String registry;
+ private final List images;
+ private final String tag;
+ private final String watchtowerUrl;
+ private final String watchtowerToken;
+
+ private final Map baselineDigests = new ConcurrentHashMap<>();
+
+ public UpdateCheckService(
+ RestTemplateBuilder builder,
+ @Value("${update-check.registry:}") String registry,
+ @Value("${update-check.images:}") String imagesCsv,
+ @Value("${update-check.tag:latest}") String tag,
+ @Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
+ @Value("${update-check.watchtower-token:}") String watchtowerToken) {
+ this.http = builder
+ .setConnectTimeout(Duration.ofSeconds(5))
+ .setReadTimeout(Duration.ofSeconds(15))
+ .build();
+ this.registry = normalizeRegistry(registry);
+ this.images = parseImages(imagesCsv);
+ this.tag = tag;
+ this.watchtowerUrl = watchtowerUrl;
+ this.watchtowerToken = watchtowerToken;
+ }
+
+ @PostConstruct
+ void initBaseline() {
+ if (!isEnabled()) {
+ log.info("Update check disabled (WATCHTOWER_TOKEN not set)");
+ return;
+ }
+ log.info("Update check enabled - registry={} images={} tag={}", registry, images, tag);
+ for (String image : images) {
+ try {
+ String digest = fetchRemoteDigest(image);
+ if (digest != null) {
+ baselineDigests.put(image, digest);
+ log.debug("Baseline digest for {} = {}", image, digest);
+ }
+ } catch (Exception e) {
+ log.warn("Cannot baseline digest for {}: {}", image, e.getMessage());
+ }
+ }
+ }
+
+ public boolean isEnabled() {
+ return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
+ }
+
+ public UpdateStatus check() {
+ if (!isEnabled()) {
+ return new UpdateStatus(false, false, List.of(), Instant.now());
+ }
+ List statuses = new ArrayList<>();
+ boolean anyUpdate = false;
+ for (String image : images) {
+ String baseline = baselineDigests.get(image);
+ String remote = null;
+ try {
+ remote = fetchRemoteDigest(image);
+ } catch (Exception e) {
+ log.warn("Check failed for {}: {}", image, e.getMessage());
+ }
+ // Si on n'a pas de baseline (echec au boot), on l'aligne maintenant
+ // pour eviter un faux positif "MAJ dispo".
+ if (baseline == null && remote != null) {
+ baselineDigests.put(image, remote);
+ baseline = remote;
+ }
+ boolean updateAvailable = baseline != null && remote != null && !baseline.equals(remote);
+ if (updateAvailable) anyUpdate = true;
+ statuses.add(new ImageStatus(image, baseline, remote, updateAvailable));
+ }
+ return new UpdateStatus(true, anyUpdate, statuses, Instant.now());
+ }
+
+ public void apply() {
+ if (!isEnabled()) {
+ throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
+ }
+ HttpHeaders headers = new HttpHeaders();
+ headers.setBearerAuth(watchtowerToken);
+ // Watchtower /v1/update declenche un scan+update immediat de tous les
+ // conteneurs labellises. La reponse est synchrone et peut prendre
+ // plusieurs secondes; en cas de redemarrage de core, le client
+ // recevra une connexion coupee — c'est attendu, l'UI le gere.
+ http.exchange(
+ watchtowerUrl + "/v1/update",
+ HttpMethod.POST,
+ new HttpEntity<>(headers),
+ Void.class);
+ }
+
+ // -----------------------------------------------------------------------
+ // Registry HTTP API v2
+ // -----------------------------------------------------------------------
+
+ private String fetchRemoteDigest(String image) {
+ String url = registry + "/v2/" + image + "/manifests/" + tag;
+ HttpHeaders headers = new HttpHeaders();
+ headers.setAccept(MANIFEST_ACCEPT);
+ try {
+ return digestCall(url, headers);
+ } catch (HttpClientErrorException.Unauthorized e) {
+ String www = e.getResponseHeaders() == null ? null
+ : e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
+ String token = obtainBearerToken(www);
+ if (token == null) {
+ log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
+ return null;
+ }
+ headers.setBearerAuth(token);
+ return digestCall(url, headers);
+ }
+ }
+
+ private String digestCall(String url, HttpHeaders headers) {
+ ResponseEntity resp = http.exchange(
+ url, HttpMethod.HEAD, new HttpEntity<>(headers), Void.class);
+ return resp.getHeaders().getFirst("Docker-Content-Digest");
+ }
+
+ /**
+ * Suit le challenge {@code WWW-Authenticate: Bearer realm="...",service="...",scope="..."}
+ * pour obtenir un jeton (anonyme — suffisant pour les images publiques).
+ */
+ @SuppressWarnings("rawtypes")
+ private String obtainBearerToken(String wwwAuth) {
+ if (wwwAuth == null) return null;
+ String prefix = "Bearer ";
+ if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
+ Map params = parseAuthParams(wwwAuth.substring(prefix.length()));
+ String realm = params.get("realm");
+ if (realm == null) return null;
+ StringBuilder url = new StringBuilder(realm);
+ boolean hasQuery = realm.contains("?");
+ for (String key : new String[]{"service", "scope"}) {
+ String v = params.get(key);
+ if (v != null) {
+ url.append(hasQuery ? '&' : '?')
+ .append(key).append('=')
+ .append(URLEncoder.encode(v, StandardCharsets.UTF_8));
+ hasQuery = true;
+ }
+ }
+ try {
+ ResponseEntity