From bc86c57d78423b74985e01f703248bdceeda1357 Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 24 May 2026 07:37:26 +0200 Subject: [PATCH 01/14] clean arch --- .../api/adapter/in/web/BonsaiController.java | 43 +++++++++++++++++++ .../api/adapter/in/web/dto/BonsaiRequest.java | 3 ++ .../adapter/in/web/dto/BonsaiResponse.java | 12 ++++++ .../persistence/InMemoryBonsaiRepository.java | 28 ++++++++++++ .../port/in/CreateBonsaiUseCase.java | 10 +++++ .../application/port/in/GetBonsaiUseCase.java | 13 ++++++ .../port/out/BonsaiRepository.java | 16 +++++++ .../application/usecase/BonsaiService.java | 36 ++++++++++++++++ .../java/fr/bonsai/api/config/BeanConfig.java | 21 +++++++++ .../fr/bonsai/api/domain/model/Bonsai.java | 27 ++++++++++++ 10 files changed, 209 insertions(+) create mode 100644 src/main/java/fr/bonsai/api/adapter/in/web/BonsaiController.java create mode 100644 src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiRequest.java create mode 100644 src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiResponse.java create mode 100644 src/main/java/fr/bonsai/api/adapter/out/persistence/InMemoryBonsaiRepository.java create mode 100644 src/main/java/fr/bonsai/api/application/port/in/CreateBonsaiUseCase.java create mode 100644 src/main/java/fr/bonsai/api/application/port/in/GetBonsaiUseCase.java create mode 100644 src/main/java/fr/bonsai/api/application/port/out/BonsaiRepository.java create mode 100644 src/main/java/fr/bonsai/api/application/usecase/BonsaiService.java create mode 100644 src/main/java/fr/bonsai/api/config/BeanConfig.java create mode 100644 src/main/java/fr/bonsai/api/domain/model/Bonsai.java diff --git a/src/main/java/fr/bonsai/api/adapter/in/web/BonsaiController.java b/src/main/java/fr/bonsai/api/adapter/in/web/BonsaiController.java new file mode 100644 index 0000000..f84731d --- /dev/null +++ b/src/main/java/fr/bonsai/api/adapter/in/web/BonsaiController.java @@ -0,0 +1,43 @@ +package fr.bonsai.api.adapter.in.web; + +import fr.bonsai.api.adapter.in.web.dto.BonsaiRequest; +import fr.bonsai.api.adapter.in.web.dto.BonsaiResponse; +import fr.bonsai.api.application.port.in.CreateBonsaiUseCase; +import fr.bonsai.api.application.port.in.GetBonsaiUseCase; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/bonsais") +public class BonsaiController { + + private final GetBonsaiUseCase getBonsaiUseCase; + private final CreateBonsaiUseCase createBonsaiUseCase; + + public BonsaiController(GetBonsaiUseCase getBonsaiUseCase, CreateBonsaiUseCase createBonsaiUseCase) { + this.getBonsaiUseCase = getBonsaiUseCase; + this.createBonsaiUseCase = createBonsaiUseCase; + } + + @GetMapping + public List getAll() { + return getBonsaiUseCase.getAll().stream() + .map(BonsaiResponse::from) + .toList(); + } + + @GetMapping("/{id}") + public BonsaiResponse getById(@PathVariable UUID id) { + return BonsaiResponse.from(getBonsaiUseCase.getById(id)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public BonsaiResponse create(@RequestBody BonsaiRequest request) { + var command = new CreateBonsaiUseCase.Command(request.name(), request.species(), request.ageYears()); + return BonsaiResponse.from(createBonsaiUseCase.create(command)); + } +} diff --git a/src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiRequest.java b/src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiRequest.java new file mode 100644 index 0000000..cecec45 --- /dev/null +++ b/src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiRequest.java @@ -0,0 +1,3 @@ +package fr.bonsai.api.adapter.in.web.dto; + +public record BonsaiRequest(String name, String species, int ageYears) {} diff --git a/src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiResponse.java b/src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiResponse.java new file mode 100644 index 0000000..83b7977 --- /dev/null +++ b/src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiResponse.java @@ -0,0 +1,12 @@ +package fr.bonsai.api.adapter.in.web.dto; + +import fr.bonsai.api.domain.model.Bonsai; + +import java.util.UUID; + +public record BonsaiResponse(UUID id, String name, String species, int ageYears) { + + public static BonsaiResponse from(Bonsai bonsai) { + return new BonsaiResponse(bonsai.getId(), bonsai.getName(), bonsai.getSpecies(), bonsai.getAgeYears()); + } +} diff --git a/src/main/java/fr/bonsai/api/adapter/out/persistence/InMemoryBonsaiRepository.java b/src/main/java/fr/bonsai/api/adapter/out/persistence/InMemoryBonsaiRepository.java new file mode 100644 index 0000000..bae9820 --- /dev/null +++ b/src/main/java/fr/bonsai/api/adapter/out/persistence/InMemoryBonsaiRepository.java @@ -0,0 +1,28 @@ +package fr.bonsai.api.adapter.out.persistence; + +import fr.bonsai.api.application.port.out.BonsaiRepository; +import fr.bonsai.api.domain.model.Bonsai; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryBonsaiRepository implements BonsaiRepository { + + private final Map store = new ConcurrentHashMap<>(); + + @Override + public List findAll() { + return List.copyOf(store.values()); + } + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Bonsai save(Bonsai bonsai) { + store.put(bonsai.getId(), bonsai); + return bonsai; + } +} diff --git a/src/main/java/fr/bonsai/api/application/port/in/CreateBonsaiUseCase.java b/src/main/java/fr/bonsai/api/application/port/in/CreateBonsaiUseCase.java new file mode 100644 index 0000000..a804940 --- /dev/null +++ b/src/main/java/fr/bonsai/api/application/port/in/CreateBonsaiUseCase.java @@ -0,0 +1,10 @@ +package fr.bonsai.api.application.port.in; + +import fr.bonsai.api.domain.model.Bonsai; + +public interface CreateBonsaiUseCase { + + record Command(String name, String species, int ageYears) {} + + Bonsai create(Command command); +} diff --git a/src/main/java/fr/bonsai/api/application/port/in/GetBonsaiUseCase.java b/src/main/java/fr/bonsai/api/application/port/in/GetBonsaiUseCase.java new file mode 100644 index 0000000..63bbad6 --- /dev/null +++ b/src/main/java/fr/bonsai/api/application/port/in/GetBonsaiUseCase.java @@ -0,0 +1,13 @@ +package fr.bonsai.api.application.port.in; + +import fr.bonsai.api.domain.model.Bonsai; + +import java.util.List; +import java.util.UUID; + +public interface GetBonsaiUseCase { + + List getAll(); + + Bonsai getById(UUID id); +} diff --git a/src/main/java/fr/bonsai/api/application/port/out/BonsaiRepository.java b/src/main/java/fr/bonsai/api/application/port/out/BonsaiRepository.java new file mode 100644 index 0000000..3817ead --- /dev/null +++ b/src/main/java/fr/bonsai/api/application/port/out/BonsaiRepository.java @@ -0,0 +1,16 @@ +package fr.bonsai.api.application.port.out; + +import fr.bonsai.api.domain.model.Bonsai; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface BonsaiRepository { + + List findAll(); + + Optional findById(UUID id); + + Bonsai save(Bonsai bonsai); +} diff --git a/src/main/java/fr/bonsai/api/application/usecase/BonsaiService.java b/src/main/java/fr/bonsai/api/application/usecase/BonsaiService.java new file mode 100644 index 0000000..55a5fbe --- /dev/null +++ b/src/main/java/fr/bonsai/api/application/usecase/BonsaiService.java @@ -0,0 +1,36 @@ +package fr.bonsai.api.application.usecase; + +import fr.bonsai.api.application.port.in.CreateBonsaiUseCase; +import fr.bonsai.api.application.port.in.GetBonsaiUseCase; +import fr.bonsai.api.application.port.out.BonsaiRepository; +import fr.bonsai.api.domain.model.Bonsai; + +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; + +public class BonsaiService implements GetBonsaiUseCase, CreateBonsaiUseCase { + + private final BonsaiRepository repository; + + public BonsaiService(BonsaiRepository repository) { + this.repository = repository; + } + + @Override + public List getAll() { + return repository.findAll(); + } + + @Override + public Bonsai getById(UUID id) { + return repository.findById(id) + .orElseThrow(() -> new NoSuchElementException("Bonsai not found: " + id)); + } + + @Override + public Bonsai create(Command command) { + Bonsai bonsai = Bonsai.create(command.name(), command.species(), command.ageYears()); + return repository.save(bonsai); + } +} diff --git a/src/main/java/fr/bonsai/api/config/BeanConfig.java b/src/main/java/fr/bonsai/api/config/BeanConfig.java new file mode 100644 index 0000000..14adc38 --- /dev/null +++ b/src/main/java/fr/bonsai/api/config/BeanConfig.java @@ -0,0 +1,21 @@ +package fr.bonsai.api.config; + +import fr.bonsai.api.adapter.out.persistence.InMemoryBonsaiRepository; +import fr.bonsai.api.application.port.out.BonsaiRepository; +import fr.bonsai.api.application.usecase.BonsaiService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class BeanConfig { + + @Bean + public BonsaiRepository bonsaiRepository() { + return new InMemoryBonsaiRepository(); + } + + @Bean + public BonsaiService bonsaiService(BonsaiRepository repository) { + return new BonsaiService(repository); + } +} diff --git a/src/main/java/fr/bonsai/api/domain/model/Bonsai.java b/src/main/java/fr/bonsai/api/domain/model/Bonsai.java new file mode 100644 index 0000000..37ef7f4 --- /dev/null +++ b/src/main/java/fr/bonsai/api/domain/model/Bonsai.java @@ -0,0 +1,27 @@ +package fr.bonsai.api.domain.model; + +import java.util.UUID; + +public class Bonsai { + + private final UUID id; + private String name; + private String species; + private int ageYears; + + public Bonsai(UUID id, String name, String species, int ageYears) { + this.id = id; + this.name = name; + this.species = species; + this.ageYears = ageYears; + } + + public static Bonsai create(String name, String species, int ageYears) { + return new Bonsai(UUID.randomUUID(), name, species, ageYears); + } + + public UUID getId() { return id; } + public String getName() { return name; } + public String getSpecies() { return species; } + public int getAgeYears() { return ageYears; } +} From a43ad25ee30a284f3e4460eaa615a0e09b4d1ca6 Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 24 May 2026 07:41:25 +0200 Subject: [PATCH 02/14] config gitea test + release --- .gitea/workflows/ci.yml | 35 +++++++++++++++++++++++++++ .gitea/workflows/release.yml | 47 ++++++++++++++++++++++++++++++++++++ Dockerfile | 5 ++++ 3 files changed, 87 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 Dockerfile diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..9842003 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: + - '**' + pull_request: + branches: + - main + +jobs: + test: + name: Tests & couverture + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: https://github.com/actions/checkout@v4 + + - name: Setup Java 25 + uses: https://github.com/actions/setup-java@v4 + with: + java-version: '25' + distribution: 'temurin' + cache: 'gradle' + + - name: Run tests + run: ./gradlew test + + - name: Upload test report + if: failure() + uses: https://github.com/actions/upload-artifact@v4 + with: + name: test-report + path: build/reports/tests/test/ diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..918e414 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,47 @@ +name: Release + +on: + release: + types: [published] + +jobs: + docker: + name: Build & push Docker image + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: https://github.com/actions/checkout@v4 + + - name: Setup Java 25 + uses: https://github.com/actions/setup-java@v4 + with: + java-version: '25' + distribution: 'temurin' + cache: 'gradle' + + - name: Build JAR + run: ./gradlew build -x test + + - name: Set up Docker Buildx + uses: https://github.com/docker/setup-buildx-action@v3 + + - name: Login to Gitea container registry + uses: https://github.com/docker/login-action@v3 + with: + registry: git.goutailler-olivier.com + username: ${{ gitea.actor }} + password: ${{ secrets.RELEASE_TOKEN }} + + - name: Set lowercase repo name + id: repo + run: echo "name=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + + - name: Build and push + uses: https://github.com/docker/build-push-action@v6 + with: + context: . + push: true + tags: | + git.goutailler-olivier.com/${{ steps.repo.outputs.name }}:${{ gitea.ref_name }} + git.goutailler-olivier.com/${{ steps.repo.outputs.name }}:latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f79df47 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:25-jre-alpine +WORKDIR /app +COPY build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] From e6d06cb82fe608bf0c9ff4a8f7c81833bd02e08e Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 24 May 2026 09:27:43 +0200 Subject: [PATCH 03/14] api issue --- Dockerfile | 10 +- README.md | 130 ++++++++++++++++++ build.gradle | 9 +- docker-compose.yml | 33 +++++ .../api/adapter/in/web/BonsaiController.java | 43 ------ .../api/adapter/in/web/IssueController.java | 95 +++++++++++++ .../api/adapter/in/web/dto/BonsaiRequest.java | 3 - .../adapter/in/web/dto/BonsaiResponse.java | 12 -- .../api/adapter/in/web/dto/CommentDto.java | 26 ++++ .../api/adapter/in/web/dto/IssueRequest.java | 18 +++ .../api/adapter/in/web/dto/IssueResponse.java | 39 ++++++ .../web/exception/GlobalExceptionHandler.java | 22 +++ .../out/persistence/CommentJpaEntity.java | 36 +++++ .../persistence/InMemoryBonsaiRepository.java | 28 ---- .../out/persistence/IssueJpaEntity.java | 76 ++++++++++ .../out/persistence/IssueJpaRepository.java | 6 + .../adapter/out/persistence/IssueMapper.java | 56 ++++++++ .../JpaIssueRepositoryAdapter.java | 77 +++++++++++ .../port/in/CreateBonsaiUseCase.java | 10 -- .../port/in/CreateIssueUseCase.java | 26 ++++ .../port/in/DeleteIssueUseCase.java | 6 + .../application/port/in/GetBonsaiUseCase.java | 13 -- .../application/port/in/GetIssuesUseCase.java | 12 ++ .../port/in/UpdateIssueUseCase.java | 27 ++++ .../port/out/BonsaiRepository.java | 16 --- .../application/port/out/IssueRepository.java | 19 +++ .../application/usecase/BonsaiService.java | 36 ----- .../api/application/usecase/IssueService.java | 61 ++++++++ .../java/fr/bonsai/api/config/BeanConfig.java | 15 +- .../java/fr/bonsai/api/config/CorsConfig.java | 30 ++++ .../fr/bonsai/api/config/SecurityConfig.java | 32 +++++ .../fr/bonsai/api/domain/model/Bonsai.java | 27 ---- .../fr/bonsai/api/domain/model/Comment.java | 23 ++++ .../fr/bonsai/api/domain/model/Issue.java | 54 ++++++++ .../bonsai/api/domain/model/IssueStatus.java | 32 +++++ .../fr/bonsai/api/domain/model/IssueType.java | 34 +++++ .../fr/bonsai/api/domain/model/Priority.java | 31 +++++ src/main/resources/application.properties | 1 - src/main/resources/application.yml | 25 ++++ src/main/resources/db/migration/V1__init.sql | 27 ++++ 40 files changed, 1078 insertions(+), 198 deletions(-) create mode 100644 README.md create mode 100644 docker-compose.yml delete mode 100644 src/main/java/fr/bonsai/api/adapter/in/web/BonsaiController.java create mode 100644 src/main/java/fr/bonsai/api/adapter/in/web/IssueController.java delete mode 100644 src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiRequest.java delete mode 100644 src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiResponse.java create mode 100644 src/main/java/fr/bonsai/api/adapter/in/web/dto/CommentDto.java create mode 100644 src/main/java/fr/bonsai/api/adapter/in/web/dto/IssueRequest.java create mode 100644 src/main/java/fr/bonsai/api/adapter/in/web/dto/IssueResponse.java create mode 100644 src/main/java/fr/bonsai/api/adapter/in/web/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/fr/bonsai/api/adapter/out/persistence/CommentJpaEntity.java delete mode 100644 src/main/java/fr/bonsai/api/adapter/out/persistence/InMemoryBonsaiRepository.java create mode 100644 src/main/java/fr/bonsai/api/adapter/out/persistence/IssueJpaEntity.java create mode 100644 src/main/java/fr/bonsai/api/adapter/out/persistence/IssueJpaRepository.java create mode 100644 src/main/java/fr/bonsai/api/adapter/out/persistence/IssueMapper.java create mode 100644 src/main/java/fr/bonsai/api/adapter/out/persistence/JpaIssueRepositoryAdapter.java delete mode 100644 src/main/java/fr/bonsai/api/application/port/in/CreateBonsaiUseCase.java create mode 100644 src/main/java/fr/bonsai/api/application/port/in/CreateIssueUseCase.java create mode 100644 src/main/java/fr/bonsai/api/application/port/in/DeleteIssueUseCase.java delete mode 100644 src/main/java/fr/bonsai/api/application/port/in/GetBonsaiUseCase.java create mode 100644 src/main/java/fr/bonsai/api/application/port/in/GetIssuesUseCase.java create mode 100644 src/main/java/fr/bonsai/api/application/port/in/UpdateIssueUseCase.java delete mode 100644 src/main/java/fr/bonsai/api/application/port/out/BonsaiRepository.java create mode 100644 src/main/java/fr/bonsai/api/application/port/out/IssueRepository.java delete mode 100644 src/main/java/fr/bonsai/api/application/usecase/BonsaiService.java create mode 100644 src/main/java/fr/bonsai/api/application/usecase/IssueService.java create mode 100644 src/main/java/fr/bonsai/api/config/CorsConfig.java create mode 100644 src/main/java/fr/bonsai/api/config/SecurityConfig.java delete mode 100644 src/main/java/fr/bonsai/api/domain/model/Bonsai.java create mode 100644 src/main/java/fr/bonsai/api/domain/model/Comment.java create mode 100644 src/main/java/fr/bonsai/api/domain/model/Issue.java create mode 100644 src/main/java/fr/bonsai/api/domain/model/IssueStatus.java create mode 100644 src/main/java/fr/bonsai/api/domain/model/IssueType.java create mode 100644 src/main/java/fr/bonsai/api/domain/model/Priority.java delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/db/migration/V1__init.sql diff --git a/Dockerfile b/Dockerfile index f79df47..65b3b7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,13 @@ +FROM eclipse-temurin:25-jdk-alpine AS build +WORKDIR /app +COPY gradlew settings.gradle build.gradle ./ +COPY gradle ./gradle +RUN ./gradlew dependencies --no-daemon -q +COPY src ./src +RUN ./gradlew build -x test --no-daemon + FROM eclipse-temurin:25-jre-alpine WORKDIR /app -COPY build/libs/*.jar app.jar +COPY --from=build /app/build/libs/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9b0dee --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# Bonsai API + +API REST de gestion d'issues (type Jira léger), construite avec Spring Boot 3.5 / Java 25 en Clean Architecture. + +--- + +## Prérequis + +| Outil | Version minimale | +|---|---| +| Java (JDK) | 25 | +| Docker + Docker Compose | 24+ | +| Gradle (wrapper inclus) | 8+ | + +--- + +## Démarrage en développement + +### 1. Cloner le dépôt + +```bash +git clone https://git.goutailler-olivier.com//bonsai-api.git +cd bonsai-api +``` + +### 2. Démarrer la base de données + +```bash +docker compose up db -d +``` + +PostgreSQL démarre sur `localhost:5432`, base `bonsai`, utilisateur `bonsai`, mot de passe `bonsai`. + +### 3. Lancer l'API + +```bash +./gradlew bootRun +``` + +Flyway applique automatiquement la migration `V1__init.sql` au premier démarrage. +L'API est disponible sur `http://localhost:8080`. + +--- + +## Démarrage complet via Docker Compose + +Pour lancer l'API et la base de données en une seule commande : + +```bash +docker compose up --build +``` + +Pour arrêter et supprimer les conteneurs : + +```bash +docker compose down +``` + +Pour supprimer également le volume PostgreSQL (réinitialise la base) : + +```bash +docker compose down -v +``` + +--- + +## Variables d'environnement + +Les valeurs par défaut conviennent pour le développement local. +En production, surcharger les variables suivantes : + +| Variable | Défaut | Description | +|---|---|---| +| `DATASOURCE_URL` | `jdbc:postgresql://localhost:5432/bonsai` | URL JDBC PostgreSQL | +| `DATASOURCE_USERNAME` | `bonsai` | Utilisateur base de données | +| `DATASOURCE_PASSWORD` | `bonsai` | Mot de passe base de données | +| `KEYCLOAK_JWKS_URI` | `https://auth.goutailler-olivier.com/realms/bonsai/protocol/openid-connect/certs` | Endpoint JWKS Keycloak | +| `CORS_ALLOWED_ORIGIN_PROD` | `https://bonsai.goutailler-olivier.com` | Origine CORS de production | + +--- + +## Sécurité + +Toutes les routes nécessitent un token JWT Bearer valide, émis par Keycloak : + +- **Realm** : `bonsai` +- **Client** : `bonsai-webapp` +- **Issuer** : `https://auth.goutailler-olivier.com/realms/bonsai` + +```http +Authorization: Bearer +``` + +--- + +## Endpoints + +| Méthode | Route | Description | +|---|---|---| +| `GET` | `/issues` | Liste toutes les issues | +| `POST` | `/issues` | Crée une issue | +| `PUT` | `/issues/{id}` | Remplace une issue complète | +| `DELETE` | `/issues/{id}` | Supprime une issue (204) | + +--- + +## Lancer les tests + +```bash +./gradlew test +``` + +Le rapport HTML est généré dans `build/reports/tests/test/index.html`. + +--- + +## Structure du projet + +``` +src/main/java/fr/bonsai/api/ +├── domain/model/ # Entités métier (sans dépendance Spring) +├── application/ +│ ├── port/in/ # Interfaces des use cases +│ ├── port/out/ # Interface du repository +│ └── usecase/ # Logique métier (IssueService) +├── adapter/ +│ ├── in/web/ # Controllers REST et DTOs +│ └── out/persistence/ # Entités JPA et adaptateur repository +└── config/ # Configuration Spring (Security, CORS, Beans) +``` diff --git a/build.gradle b/build.gradle index 92136a5..5606bbb 100644 --- a/build.gradle +++ b/build.gradle @@ -19,9 +19,16 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-database-postgresql' + runtimeOnly 'org.postgresql:postgresql' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ff42023 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: bonsai + POSTGRES_USER: bonsai + POSTGRES_PASSWORD: bonsai + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U bonsai"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: . + ports: + - "8080:8080" + environment: + DATASOURCE_URL: jdbc:postgresql://db:5432/bonsai + DATASOURCE_USERNAME: bonsai + DATASOURCE_PASSWORD: bonsai + KEYCLOAK_JWKS_URI: https://auth.goutailler-olivier.com/realms/bonsai/protocol/openid-connect/certs + CORS_ALLOWED_ORIGIN_PROD: https://bonsai.goutailler-olivier.com + depends_on: + db: + condition: service_healthy + +volumes: + postgres_data: diff --git a/src/main/java/fr/bonsai/api/adapter/in/web/BonsaiController.java b/src/main/java/fr/bonsai/api/adapter/in/web/BonsaiController.java deleted file mode 100644 index f84731d..0000000 --- a/src/main/java/fr/bonsai/api/adapter/in/web/BonsaiController.java +++ /dev/null @@ -1,43 +0,0 @@ -package fr.bonsai.api.adapter.in.web; - -import fr.bonsai.api.adapter.in.web.dto.BonsaiRequest; -import fr.bonsai.api.adapter.in.web.dto.BonsaiResponse; -import fr.bonsai.api.application.port.in.CreateBonsaiUseCase; -import fr.bonsai.api.application.port.in.GetBonsaiUseCase; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.UUID; - -@RestController -@RequestMapping("/api/bonsais") -public class BonsaiController { - - private final GetBonsaiUseCase getBonsaiUseCase; - private final CreateBonsaiUseCase createBonsaiUseCase; - - public BonsaiController(GetBonsaiUseCase getBonsaiUseCase, CreateBonsaiUseCase createBonsaiUseCase) { - this.getBonsaiUseCase = getBonsaiUseCase; - this.createBonsaiUseCase = createBonsaiUseCase; - } - - @GetMapping - public List getAll() { - return getBonsaiUseCase.getAll().stream() - .map(BonsaiResponse::from) - .toList(); - } - - @GetMapping("/{id}") - public BonsaiResponse getById(@PathVariable UUID id) { - return BonsaiResponse.from(getBonsaiUseCase.getById(id)); - } - - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - public BonsaiResponse create(@RequestBody BonsaiRequest request) { - var command = new CreateBonsaiUseCase.Command(request.name(), request.species(), request.ageYears()); - return BonsaiResponse.from(createBonsaiUseCase.create(command)); - } -} diff --git a/src/main/java/fr/bonsai/api/adapter/in/web/IssueController.java b/src/main/java/fr/bonsai/api/adapter/in/web/IssueController.java new file mode 100644 index 0000000..c0afe1c --- /dev/null +++ b/src/main/java/fr/bonsai/api/adapter/in/web/IssueController.java @@ -0,0 +1,95 @@ +package fr.bonsai.api.adapter.in.web; + +import fr.bonsai.api.adapter.in.web.dto.IssueRequest; +import fr.bonsai.api.adapter.in.web.dto.IssueResponse; +import fr.bonsai.api.application.port.in.*; +import fr.bonsai.api.domain.model.*; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequestMapping("/issues") +public class IssueController { + + private final GetIssuesUseCase getIssuesUseCase; + private final CreateIssueUseCase createIssueUseCase; + private final UpdateIssueUseCase updateIssueUseCase; + private final DeleteIssueUseCase deleteIssueUseCase; + + public IssueController(GetIssuesUseCase getIssuesUseCase, + CreateIssueUseCase createIssueUseCase, + UpdateIssueUseCase updateIssueUseCase, + DeleteIssueUseCase deleteIssueUseCase) { + this.getIssuesUseCase = getIssuesUseCase; + this.createIssueUseCase = createIssueUseCase; + this.updateIssueUseCase = updateIssueUseCase; + this.deleteIssueUseCase = deleteIssueUseCase; + } + + @GetMapping + public List getAll() { + return getIssuesUseCase.getAll().stream().map(IssueResponse::from).toList(); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public IssueResponse create(@RequestBody IssueRequest request) { + var command = new CreateIssueUseCase.Command( + IssueType.fromValue(request.type()), + request.assignee(), + request.epic(), + request.name(), + parseDate(request.dueDate()), + request.description(), + request.estimatedTime(), + nullSafe(request.dependsOnIds()), + toComments(request), + Priority.fromValue(request.priority()), + IssueStatus.fromValue(request.status()), + request.progress() + ); + return IssueResponse.from(createIssueUseCase.create(command)); + } + + @PutMapping("/{id}") + public IssueResponse update(@PathVariable Long id, @RequestBody IssueRequest request) { + var command = new UpdateIssueUseCase.Command( + id, + IssueType.fromValue(request.type()), + request.assignee(), + request.epic(), + request.name(), + parseDate(request.dueDate()), + request.description(), + request.estimatedTime(), + nullSafe(request.dependsOnIds()), + toComments(request), + Priority.fromValue(request.priority()), + IssueStatus.fromValue(request.status()), + request.progress() + ); + return IssueResponse.from(updateIssueUseCase.update(command)); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Long id) { + deleteIssueUseCase.delete(id); + } + + private LocalDate parseDate(String date) { + return date != null && !date.isBlank() ? LocalDate.parse(date) : null; + } + + private List nullSafe(List list) { + return list != null ? list : List.of(); + } + + private List toComments(IssueRequest request) { + if (request.comments() == null) return List.of(); + return request.comments().stream().map(dto -> dto.toDomain()).toList(); + } +} diff --git a/src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiRequest.java b/src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiRequest.java deleted file mode 100644 index cecec45..0000000 --- a/src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiRequest.java +++ /dev/null @@ -1,3 +0,0 @@ -package fr.bonsai.api.adapter.in.web.dto; - -public record BonsaiRequest(String name, String species, int ageYears) {} diff --git a/src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiResponse.java b/src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiResponse.java deleted file mode 100644 index 83b7977..0000000 --- a/src/main/java/fr/bonsai/api/adapter/in/web/dto/BonsaiResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package fr.bonsai.api.adapter.in.web.dto; - -import fr.bonsai.api.domain.model.Bonsai; - -import java.util.UUID; - -public record BonsaiResponse(UUID id, String name, String species, int ageYears) { - - public static BonsaiResponse from(Bonsai bonsai) { - return new BonsaiResponse(bonsai.getId(), bonsai.getName(), bonsai.getSpecies(), bonsai.getAgeYears()); - } -} diff --git a/src/main/java/fr/bonsai/api/adapter/in/web/dto/CommentDto.java b/src/main/java/fr/bonsai/api/adapter/in/web/dto/CommentDto.java new file mode 100644 index 0000000..ded1b63 --- /dev/null +++ b/src/main/java/fr/bonsai/api/adapter/in/web/dto/CommentDto.java @@ -0,0 +1,26 @@ +package fr.bonsai.api.adapter.in.web.dto; + +import fr.bonsai.api.domain.model.Comment; + +import java.time.Instant; + +public record CommentDto(Long id, String text, String createdAt, String updatedAt) { + + public static CommentDto from(Comment comment) { + return new CommentDto( + comment.getId(), + comment.getText(), + comment.getCreatedAt() != null ? comment.getCreatedAt().toString() : null, + comment.getUpdatedAt() != null ? comment.getUpdatedAt().toString() : null + ); + } + + public Comment toDomain() { + return new Comment( + id, + text, + createdAt != null ? Instant.parse(createdAt) : Instant.now(), + updatedAt != null ? Instant.parse(updatedAt) : null + ); + } +} diff --git a/src/main/java/fr/bonsai/api/adapter/in/web/dto/IssueRequest.java b/src/main/java/fr/bonsai/api/adapter/in/web/dto/IssueRequest.java new file mode 100644 index 0000000..6a8c450 --- /dev/null +++ b/src/main/java/fr/bonsai/api/adapter/in/web/dto/IssueRequest.java @@ -0,0 +1,18 @@ +package fr.bonsai.api.adapter.in.web.dto; + +import java.util.List; + +public record IssueRequest( + String type, + String assignee, + String epic, + String name, + String dueDate, + String description, + Double estimatedTime, + List dependsOnIds, + List comments, + String priority, + String status, + int progress +) {} diff --git a/src/main/java/fr/bonsai/api/adapter/in/web/dto/IssueResponse.java b/src/main/java/fr/bonsai/api/adapter/in/web/dto/IssueResponse.java new file mode 100644 index 0000000..25f8130 --- /dev/null +++ b/src/main/java/fr/bonsai/api/adapter/in/web/dto/IssueResponse.java @@ -0,0 +1,39 @@ +package fr.bonsai.api.adapter.in.web.dto; + +import fr.bonsai.api.domain.model.Issue; + +import java.util.List; + +public record IssueResponse( + Long id, + String type, + String assignee, + String epic, + String name, + String dueDate, + String description, + Double estimatedTime, + List dependsOnIds, + List comments, + String priority, + String status, + int progress +) { + public static IssueResponse from(Issue issue) { + return new IssueResponse( + issue.getId(), + issue.getType().getValue(), + issue.getAssignee(), + issue.getEpic(), + issue.getName(), + issue.getDueDate() != null ? issue.getDueDate().toString() : null, + issue.getDescription(), + issue.getEstimatedTime(), + issue.getDependsOnIds(), + issue.getComments().stream().map(CommentDto::from).toList(), + issue.getPriority().getValue(), + issue.getStatus().getValue(), + issue.getProgress() + ); + } +} diff --git a/src/main/java/fr/bonsai/api/adapter/in/web/exception/GlobalExceptionHandler.java b/src/main/java/fr/bonsai/api/adapter/in/web/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..3722331 --- /dev/null +++ b/src/main/java/fr/bonsai/api/adapter/in/web/exception/GlobalExceptionHandler.java @@ -0,0 +1,22 @@ +package fr.bonsai.api.adapter.in.web.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.NoSuchElementException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NoSuchElementException.class) + public ProblemDetail handleNotFound(NoSuchElementException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ProblemDetail handleBadRequest(IllegalArgumentException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + } +} diff --git a/src/main/java/fr/bonsai/api/adapter/out/persistence/CommentJpaEntity.java b/src/main/java/fr/bonsai/api/adapter/out/persistence/CommentJpaEntity.java new file mode 100644 index 0000000..5eb1d28 --- /dev/null +++ b/src/main/java/fr/bonsai/api/adapter/out/persistence/CommentJpaEntity.java @@ -0,0 +1,36 @@ +package fr.bonsai.api.adapter.out.persistence; + +import jakarta.persistence.*; + +import java.time.Instant; + +@Entity +@Table(name = "comments") +public class CommentJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "issue_id", nullable = false) + private IssueJpaEntity issue; + + @Column(columnDefinition = "TEXT", nullable = false) + private String text; + + @Column(nullable = false) + private Instant createdAt; + + private Instant updatedAt; + + public Long getId() { return id; } + public IssueJpaEntity getIssue() { return issue; } + public void setIssue(IssueJpaEntity issue) { this.issue = issue; } + public String getText() { return text; } + public void setText(String text) { this.text = text; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + public Instant getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/src/main/java/fr/bonsai/api/adapter/out/persistence/InMemoryBonsaiRepository.java b/src/main/java/fr/bonsai/api/adapter/out/persistence/InMemoryBonsaiRepository.java deleted file mode 100644 index bae9820..0000000 --- a/src/main/java/fr/bonsai/api/adapter/out/persistence/InMemoryBonsaiRepository.java +++ /dev/null @@ -1,28 +0,0 @@ -package fr.bonsai.api.adapter.out.persistence; - -import fr.bonsai.api.application.port.out.BonsaiRepository; -import fr.bonsai.api.domain.model.Bonsai; - -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; - -public class InMemoryBonsaiRepository implements BonsaiRepository { - - private final Map store = new ConcurrentHashMap<>(); - - @Override - public List findAll() { - return List.copyOf(store.values()); - } - - @Override - public Optional findById(UUID id) { - return Optional.ofNullable(store.get(id)); - } - - @Override - public Bonsai save(Bonsai bonsai) { - store.put(bonsai.getId(), bonsai); - return bonsai; - } -} diff --git a/src/main/java/fr/bonsai/api/adapter/out/persistence/IssueJpaEntity.java b/src/main/java/fr/bonsai/api/adapter/out/persistence/IssueJpaEntity.java new file mode 100644 index 0000000..8a23451 --- /dev/null +++ b/src/main/java/fr/bonsai/api/adapter/out/persistence/IssueJpaEntity.java @@ -0,0 +1,76 @@ +package fr.bonsai.api.adapter.out.persistence; + +import jakarta.persistence.*; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "issues") +public class IssueJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String type; + + private String assignee; + private String epic; + + @Column(nullable = false) + private String name; + + private LocalDate dueDate; + + @Column(columnDefinition = "TEXT") + private String description; + + private Double estimatedTime; + + @ElementCollection + @CollectionTable(name = "issue_depends_on", joinColumns = @JoinColumn(name = "issue_id")) + @Column(name = "depends_on_id") + private List dependsOnIds = new ArrayList<>(); + + @OneToMany(mappedBy = "issue", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("createdAt ASC") + private List comments = new ArrayList<>(); + + @Column(nullable = false, length = 50) + private String priority; + + @Column(nullable = false, length = 50) + private String status; + + @Column(nullable = false) + private int progress; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getType() { return type; } + public void setType(String type) { this.type = type; } + public String getAssignee() { return assignee; } + public void setAssignee(String assignee) { this.assignee = assignee; } + public String getEpic() { return epic; } + public void setEpic(String epic) { this.epic = epic; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public LocalDate getDueDate() { return dueDate; } + public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public Double getEstimatedTime() { return estimatedTime; } + public void setEstimatedTime(Double estimatedTime) { this.estimatedTime = estimatedTime; } + public List getDependsOnIds() { return dependsOnIds; } + public void setDependsOnIds(List dependsOnIds) { this.dependsOnIds = dependsOnIds; } + public List getComments() { return comments; } + public String getPriority() { return priority; } + public void setPriority(String priority) { this.priority = priority; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public int getProgress() { return progress; } + public void setProgress(int progress) { this.progress = progress; } +} diff --git a/src/main/java/fr/bonsai/api/adapter/out/persistence/IssueJpaRepository.java b/src/main/java/fr/bonsai/api/adapter/out/persistence/IssueJpaRepository.java new file mode 100644 index 0000000..3897fc4 --- /dev/null +++ b/src/main/java/fr/bonsai/api/adapter/out/persistence/IssueJpaRepository.java @@ -0,0 +1,6 @@ +package fr.bonsai.api.adapter.out.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface IssueJpaRepository extends JpaRepository { +} diff --git a/src/main/java/fr/bonsai/api/adapter/out/persistence/IssueMapper.java b/src/main/java/fr/bonsai/api/adapter/out/persistence/IssueMapper.java new file mode 100644 index 0000000..2c6b70d --- /dev/null +++ b/src/main/java/fr/bonsai/api/adapter/out/persistence/IssueMapper.java @@ -0,0 +1,56 @@ +package fr.bonsai.api.adapter.out.persistence; + +import fr.bonsai.api.domain.model.*; + +import java.util.ArrayList; +import java.util.List; + +public class IssueMapper { + + public static Issue toDomain(IssueJpaEntity entity) { + List comments = entity.getComments().stream() + .map(c -> new Comment(c.getId(), c.getText(), c.getCreatedAt(), c.getUpdatedAt())) + .toList(); + return new Issue( + entity.getId(), + IssueType.fromValue(entity.getType()), + entity.getAssignee(), + entity.getEpic(), + entity.getName(), + entity.getDueDate(), + entity.getDescription(), + entity.getEstimatedTime(), + new ArrayList<>(entity.getDependsOnIds()), + comments, + Priority.fromValue(entity.getPriority()), + IssueStatus.fromValue(entity.getStatus()), + entity.getProgress() + ); + } + + public static IssueJpaEntity toJpa(Issue issue) { + IssueJpaEntity entity = new IssueJpaEntity(); + if (issue.getId() != null) entity.setId(issue.getId()); + entity.setType(issue.getType().getValue()); + entity.setAssignee(issue.getAssignee()); + entity.setEpic(issue.getEpic()); + entity.setName(issue.getName()); + entity.setDueDate(issue.getDueDate()); + entity.setDescription(issue.getDescription()); + entity.setEstimatedTime(issue.getEstimatedTime()); + entity.setDependsOnIds(new ArrayList<>(issue.getDependsOnIds())); + entity.setPriority(issue.getPriority().getValue()); + entity.setStatus(issue.getStatus().getValue()); + entity.setProgress(issue.getProgress()); + + for (Comment c : issue.getComments()) { + CommentJpaEntity ce = new CommentJpaEntity(); + ce.setIssue(entity); + ce.setText(c.getText()); + ce.setCreatedAt(c.getCreatedAt()); + ce.setUpdatedAt(c.getUpdatedAt()); + entity.getComments().add(ce); + } + return entity; + } +} diff --git a/src/main/java/fr/bonsai/api/adapter/out/persistence/JpaIssueRepositoryAdapter.java b/src/main/java/fr/bonsai/api/adapter/out/persistence/JpaIssueRepositoryAdapter.java new file mode 100644 index 0000000..2e2f4a1 --- /dev/null +++ b/src/main/java/fr/bonsai/api/adapter/out/persistence/JpaIssueRepositoryAdapter.java @@ -0,0 +1,77 @@ +package fr.bonsai.api.adapter.out.persistence; + +import fr.bonsai.api.application.port.out.IssueRepository; +import fr.bonsai.api.domain.model.Issue; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Transactional +public class JpaIssueRepositoryAdapter implements IssueRepository { + + private final IssueJpaRepository jpaRepository; + + public JpaIssueRepositoryAdapter(IssueJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + @Transactional(readOnly = true) + public List findAll() { + return jpaRepository.findAll().stream().map(IssueMapper::toDomain).toList(); + } + + @Override + @Transactional(readOnly = true) + public Optional findById(Long id) { + return jpaRepository.findById(id).map(IssueMapper::toDomain); + } + + @Override + public Issue save(Issue issue) { + if (issue.getId() != null) { + // Update: work on the managed entity so orphanRemoval triggers correctly + IssueJpaEntity existing = jpaRepository.findById(issue.getId()) + .orElseThrow(() -> new java.util.NoSuchElementException("Issue not found: " + issue.getId())); + mergeInto(existing, issue); + return IssueMapper.toDomain(jpaRepository.save(existing)); + } + return IssueMapper.toDomain(jpaRepository.save(IssueMapper.toJpa(issue))); + } + + private void mergeInto(IssueJpaEntity target, Issue source) { + target.setType(source.getType().getValue()); + target.setAssignee(source.getAssignee()); + target.setEpic(source.getEpic()); + target.setName(source.getName()); + target.setDueDate(source.getDueDate()); + target.setDescription(source.getDescription()); + target.setEstimatedTime(source.getEstimatedTime()); + target.setDependsOnIds(new java.util.ArrayList<>(source.getDependsOnIds())); + target.setPriority(source.getPriority().getValue()); + target.setStatus(source.getStatus().getValue()); + target.setProgress(source.getProgress()); + + target.getComments().clear(); + for (fr.bonsai.api.domain.model.Comment c : source.getComments()) { + CommentJpaEntity ce = new CommentJpaEntity(); + ce.setIssue(target); + ce.setText(c.getText()); + ce.setCreatedAt(c.getCreatedAt()); + ce.setUpdatedAt(c.getUpdatedAt()); + target.getComments().add(ce); + } + } + + @Override + public void deleteById(Long id) { + jpaRepository.deleteById(id); + } + + @Override + @Transactional(readOnly = true) + public boolean existsById(Long id) { + return jpaRepository.existsById(id); + } +} diff --git a/src/main/java/fr/bonsai/api/application/port/in/CreateBonsaiUseCase.java b/src/main/java/fr/bonsai/api/application/port/in/CreateBonsaiUseCase.java deleted file mode 100644 index a804940..0000000 --- a/src/main/java/fr/bonsai/api/application/port/in/CreateBonsaiUseCase.java +++ /dev/null @@ -1,10 +0,0 @@ -package fr.bonsai.api.application.port.in; - -import fr.bonsai.api.domain.model.Bonsai; - -public interface CreateBonsaiUseCase { - - record Command(String name, String species, int ageYears) {} - - Bonsai create(Command command); -} diff --git a/src/main/java/fr/bonsai/api/application/port/in/CreateIssueUseCase.java b/src/main/java/fr/bonsai/api/application/port/in/CreateIssueUseCase.java new file mode 100644 index 0000000..aaa6bf8 --- /dev/null +++ b/src/main/java/fr/bonsai/api/application/port/in/CreateIssueUseCase.java @@ -0,0 +1,26 @@ +package fr.bonsai.api.application.port.in; + +import fr.bonsai.api.domain.model.*; + +import java.time.LocalDate; +import java.util.List; + +public interface CreateIssueUseCase { + + record Command( + IssueType type, + String assignee, + String epic, + String name, + LocalDate dueDate, + String description, + Double estimatedTime, + List dependsOnIds, + List comments, + Priority priority, + IssueStatus status, + int progress + ) {} + + Issue create(Command command); +} diff --git a/src/main/java/fr/bonsai/api/application/port/in/DeleteIssueUseCase.java b/src/main/java/fr/bonsai/api/application/port/in/DeleteIssueUseCase.java new file mode 100644 index 0000000..5a75095 --- /dev/null +++ b/src/main/java/fr/bonsai/api/application/port/in/DeleteIssueUseCase.java @@ -0,0 +1,6 @@ +package fr.bonsai.api.application.port.in; + +public interface DeleteIssueUseCase { + + void delete(Long id); +} diff --git a/src/main/java/fr/bonsai/api/application/port/in/GetBonsaiUseCase.java b/src/main/java/fr/bonsai/api/application/port/in/GetBonsaiUseCase.java deleted file mode 100644 index 63bbad6..0000000 --- a/src/main/java/fr/bonsai/api/application/port/in/GetBonsaiUseCase.java +++ /dev/null @@ -1,13 +0,0 @@ -package fr.bonsai.api.application.port.in; - -import fr.bonsai.api.domain.model.Bonsai; - -import java.util.List; -import java.util.UUID; - -public interface GetBonsaiUseCase { - - List getAll(); - - Bonsai getById(UUID id); -} diff --git a/src/main/java/fr/bonsai/api/application/port/in/GetIssuesUseCase.java b/src/main/java/fr/bonsai/api/application/port/in/GetIssuesUseCase.java new file mode 100644 index 0000000..c77d096 --- /dev/null +++ b/src/main/java/fr/bonsai/api/application/port/in/GetIssuesUseCase.java @@ -0,0 +1,12 @@ +package fr.bonsai.api.application.port.in; + +import fr.bonsai.api.domain.model.Issue; + +import java.util.List; + +public interface GetIssuesUseCase { + + List getAll(); + + Issue getById(Long id); +} diff --git a/src/main/java/fr/bonsai/api/application/port/in/UpdateIssueUseCase.java b/src/main/java/fr/bonsai/api/application/port/in/UpdateIssueUseCase.java new file mode 100644 index 0000000..d77c33f --- /dev/null +++ b/src/main/java/fr/bonsai/api/application/port/in/UpdateIssueUseCase.java @@ -0,0 +1,27 @@ +package fr.bonsai.api.application.port.in; + +import fr.bonsai.api.domain.model.*; + +import java.time.LocalDate; +import java.util.List; + +public interface UpdateIssueUseCase { + + record Command( + Long id, + IssueType type, + String assignee, + String epic, + String name, + LocalDate dueDate, + String description, + Double estimatedTime, + List dependsOnIds, + List comments, + Priority priority, + IssueStatus status, + int progress + ) {} + + Issue update(Command command); +} diff --git a/src/main/java/fr/bonsai/api/application/port/out/BonsaiRepository.java b/src/main/java/fr/bonsai/api/application/port/out/BonsaiRepository.java deleted file mode 100644 index 3817ead..0000000 --- a/src/main/java/fr/bonsai/api/application/port/out/BonsaiRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package fr.bonsai.api.application.port.out; - -import fr.bonsai.api.domain.model.Bonsai; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public interface BonsaiRepository { - - List findAll(); - - Optional findById(UUID id); - - Bonsai save(Bonsai bonsai); -} diff --git a/src/main/java/fr/bonsai/api/application/port/out/IssueRepository.java b/src/main/java/fr/bonsai/api/application/port/out/IssueRepository.java new file mode 100644 index 0000000..5fb8100 --- /dev/null +++ b/src/main/java/fr/bonsai/api/application/port/out/IssueRepository.java @@ -0,0 +1,19 @@ +package fr.bonsai.api.application.port.out; + +import fr.bonsai.api.domain.model.Issue; + +import java.util.List; +import java.util.Optional; + +public interface IssueRepository { + + List findAll(); + + Optional findById(Long id); + + Issue save(Issue issue); + + void deleteById(Long id); + + boolean existsById(Long id); +} diff --git a/src/main/java/fr/bonsai/api/application/usecase/BonsaiService.java b/src/main/java/fr/bonsai/api/application/usecase/BonsaiService.java deleted file mode 100644 index 55a5fbe..0000000 --- a/src/main/java/fr/bonsai/api/application/usecase/BonsaiService.java +++ /dev/null @@ -1,36 +0,0 @@ -package fr.bonsai.api.application.usecase; - -import fr.bonsai.api.application.port.in.CreateBonsaiUseCase; -import fr.bonsai.api.application.port.in.GetBonsaiUseCase; -import fr.bonsai.api.application.port.out.BonsaiRepository; -import fr.bonsai.api.domain.model.Bonsai; - -import java.util.List; -import java.util.NoSuchElementException; -import java.util.UUID; - -public class BonsaiService implements GetBonsaiUseCase, CreateBonsaiUseCase { - - private final BonsaiRepository repository; - - public BonsaiService(BonsaiRepository repository) { - this.repository = repository; - } - - @Override - public List getAll() { - return repository.findAll(); - } - - @Override - public Bonsai getById(UUID id) { - return repository.findById(id) - .orElseThrow(() -> new NoSuchElementException("Bonsai not found: " + id)); - } - - @Override - public Bonsai create(Command command) { - Bonsai bonsai = Bonsai.create(command.name(), command.species(), command.ageYears()); - return repository.save(bonsai); - } -} diff --git a/src/main/java/fr/bonsai/api/application/usecase/IssueService.java b/src/main/java/fr/bonsai/api/application/usecase/IssueService.java new file mode 100644 index 0000000..36988a9 --- /dev/null +++ b/src/main/java/fr/bonsai/api/application/usecase/IssueService.java @@ -0,0 +1,61 @@ +package fr.bonsai.api.application.usecase; + +import fr.bonsai.api.application.port.in.*; +import fr.bonsai.api.application.port.out.IssueRepository; +import fr.bonsai.api.domain.model.Issue; + +import java.util.List; +import java.util.NoSuchElementException; + +public class IssueService implements GetIssuesUseCase, CreateIssueUseCase, UpdateIssueUseCase, DeleteIssueUseCase { + + private final IssueRepository repository; + + public IssueService(IssueRepository repository) { + this.repository = repository; + } + + @Override + public List getAll() { + return repository.findAll(); + } + + @Override + public Issue getById(Long id) { + return repository.findById(id) + .orElseThrow(() -> new NoSuchElementException("Issue not found: " + id)); + } + + @Override + public Issue create(CreateIssueUseCase.Command cmd) { + Issue issue = new Issue( + null, cmd.type(), cmd.assignee(), cmd.epic(), cmd.name(), + cmd.dueDate(), cmd.description(), cmd.estimatedTime(), + cmd.dependsOnIds(), cmd.comments(), + cmd.priority(), cmd.status(), cmd.progress() + ); + return repository.save(issue); + } + + @Override + public Issue update(UpdateIssueUseCase.Command cmd) { + if (!repository.existsById(cmd.id())) { + throw new NoSuchElementException("Issue not found: " + cmd.id()); + } + Issue issue = new Issue( + cmd.id(), cmd.type(), cmd.assignee(), cmd.epic(), cmd.name(), + cmd.dueDate(), cmd.description(), cmd.estimatedTime(), + cmd.dependsOnIds(), cmd.comments(), + cmd.priority(), cmd.status(), cmd.progress() + ); + return repository.save(issue); + } + + @Override + public void delete(Long id) { + if (!repository.existsById(id)) { + throw new NoSuchElementException("Issue not found: " + id); + } + repository.deleteById(id); + } +} diff --git a/src/main/java/fr/bonsai/api/config/BeanConfig.java b/src/main/java/fr/bonsai/api/config/BeanConfig.java index 14adc38..7f0350e 100644 --- a/src/main/java/fr/bonsai/api/config/BeanConfig.java +++ b/src/main/java/fr/bonsai/api/config/BeanConfig.java @@ -1,8 +1,9 @@ package fr.bonsai.api.config; -import fr.bonsai.api.adapter.out.persistence.InMemoryBonsaiRepository; -import fr.bonsai.api.application.port.out.BonsaiRepository; -import fr.bonsai.api.application.usecase.BonsaiService; +import fr.bonsai.api.adapter.out.persistence.IssueJpaRepository; +import fr.bonsai.api.adapter.out.persistence.JpaIssueRepositoryAdapter; +import fr.bonsai.api.application.port.out.IssueRepository; +import fr.bonsai.api.application.usecase.IssueService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -10,12 +11,12 @@ import org.springframework.context.annotation.Configuration; public class BeanConfig { @Bean - public BonsaiRepository bonsaiRepository() { - return new InMemoryBonsaiRepository(); + public IssueRepository issueRepository(IssueJpaRepository jpaRepository) { + return new JpaIssueRepositoryAdapter(jpaRepository); } @Bean - public BonsaiService bonsaiService(BonsaiRepository repository) { - return new BonsaiService(repository); + public IssueService issueService(IssueRepository issueRepository) { + return new IssueService(issueRepository); } } diff --git a/src/main/java/fr/bonsai/api/config/CorsConfig.java b/src/main/java/fr/bonsai/api/config/CorsConfig.java new file mode 100644 index 0000000..7a138a4 --- /dev/null +++ b/src/main/java/fr/bonsai/api/config/CorsConfig.java @@ -0,0 +1,30 @@ +package fr.bonsai.api.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +public class CorsConfig { + + @Value("${app.cors.allowed-origins}") + private List allowedOrigins; + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(allowedOrigins); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("Content-Type", "Authorization")); + config.setAllowCredentials(false); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/src/main/java/fr/bonsai/api/config/SecurityConfig.java b/src/main/java/fr/bonsai/api/config/SecurityConfig.java new file mode 100644 index 0000000..0221ad3 --- /dev/null +++ b/src/main/java/fr/bonsai/api/config/SecurityConfig.java @@ -0,0 +1,32 @@ +package fr.bonsai.api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(Customizer.withDefaults()) + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(Customizer.withDefaults()) + ); + return http.build(); + } +} diff --git a/src/main/java/fr/bonsai/api/domain/model/Bonsai.java b/src/main/java/fr/bonsai/api/domain/model/Bonsai.java deleted file mode 100644 index 37ef7f4..0000000 --- a/src/main/java/fr/bonsai/api/domain/model/Bonsai.java +++ /dev/null @@ -1,27 +0,0 @@ -package fr.bonsai.api.domain.model; - -import java.util.UUID; - -public class Bonsai { - - private final UUID id; - private String name; - private String species; - private int ageYears; - - public Bonsai(UUID id, String name, String species, int ageYears) { - this.id = id; - this.name = name; - this.species = species; - this.ageYears = ageYears; - } - - public static Bonsai create(String name, String species, int ageYears) { - return new Bonsai(UUID.randomUUID(), name, species, ageYears); - } - - public UUID getId() { return id; } - public String getName() { return name; } - public String getSpecies() { return species; } - public int getAgeYears() { return ageYears; } -} diff --git a/src/main/java/fr/bonsai/api/domain/model/Comment.java b/src/main/java/fr/bonsai/api/domain/model/Comment.java new file mode 100644 index 0000000..06b8833 --- /dev/null +++ b/src/main/java/fr/bonsai/api/domain/model/Comment.java @@ -0,0 +1,23 @@ +package fr.bonsai.api.domain.model; + +import java.time.Instant; + +public class Comment { + + private final Long id; + private final String text; + private final Instant createdAt; + private final Instant updatedAt; + + public Comment(Long id, String text, Instant createdAt, Instant updatedAt) { + this.id = id; + this.text = text; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public Long getId() { return id; } + public String getText() { return text; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } +} diff --git a/src/main/java/fr/bonsai/api/domain/model/Issue.java b/src/main/java/fr/bonsai/api/domain/model/Issue.java new file mode 100644 index 0000000..abfeaf8 --- /dev/null +++ b/src/main/java/fr/bonsai/api/domain/model/Issue.java @@ -0,0 +1,54 @@ +package fr.bonsai.api.domain.model; + +import java.time.LocalDate; +import java.util.List; + +public class Issue { + + private final Long id; + private final IssueType type; + private final String assignee; + private final String epic; + private final String name; + private final LocalDate dueDate; + private final String description; + private final Double estimatedTime; + private final List dependsOnIds; + private final List comments; + private final Priority priority; + private final IssueStatus status; + private final int progress; + + public Issue(Long id, IssueType type, String assignee, String epic, String name, + LocalDate dueDate, String description, Double estimatedTime, + List dependsOnIds, List comments, + Priority priority, IssueStatus status, int progress) { + this.id = id; + this.type = type; + this.assignee = assignee; + this.epic = epic; + this.name = name; + this.dueDate = dueDate; + this.description = description; + this.estimatedTime = estimatedTime; + this.dependsOnIds = dependsOnIds; + this.comments = comments; + this.priority = priority; + this.status = status; + this.progress = progress; + } + + public Long getId() { return id; } + public IssueType getType() { return type; } + public String getAssignee() { return assignee; } + public String getEpic() { return epic; } + public String getName() { return name; } + public LocalDate getDueDate() { return dueDate; } + public String getDescription() { return description; } + public Double getEstimatedTime() { return estimatedTime; } + public List getDependsOnIds() { return dependsOnIds; } + public List getComments() { return comments; } + public Priority getPriority() { return priority; } + public IssueStatus getStatus() { return status; } + public int getProgress() { return progress; } +} diff --git a/src/main/java/fr/bonsai/api/domain/model/IssueStatus.java b/src/main/java/fr/bonsai/api/domain/model/IssueStatus.java new file mode 100644 index 0000000..d999b7b --- /dev/null +++ b/src/main/java/fr/bonsai/api/domain/model/IssueStatus.java @@ -0,0 +1,32 @@ +package fr.bonsai.api.domain.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Arrays; + +public enum IssueStatus { + DRAFT("draft"), + TODO("todo"), + IN_PROGRESS("in-progress"), + DONE("done"); + + private final String value; + + IssueStatus(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static IssueStatus fromValue(String value) { + return Arrays.stream(values()) + .filter(s -> s.value.equals(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown IssueStatus: " + value)); + } +} diff --git a/src/main/java/fr/bonsai/api/domain/model/IssueType.java b/src/main/java/fr/bonsai/api/domain/model/IssueType.java new file mode 100644 index 0000000..d024856 --- /dev/null +++ b/src/main/java/fr/bonsai/api/domain/model/IssueType.java @@ -0,0 +1,34 @@ +package fr.bonsai.api.domain.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Arrays; + +public enum IssueType { + EPIC("Epic"), + BUG("Bug"), + STUDY("Study"), + STORY("Story"), + TASK("Task"), + TECHNICAL_STORY("Technical Story"); + + private final String value; + + IssueType(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static IssueType fromValue(String value) { + return Arrays.stream(values()) + .filter(t -> t.value.equals(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown IssueType: " + value)); + } +} diff --git a/src/main/java/fr/bonsai/api/domain/model/Priority.java b/src/main/java/fr/bonsai/api/domain/model/Priority.java new file mode 100644 index 0000000..18467c7 --- /dev/null +++ b/src/main/java/fr/bonsai/api/domain/model/Priority.java @@ -0,0 +1,31 @@ +package fr.bonsai.api.domain.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Arrays; + +public enum Priority { + BASSE("Basse"), + MOYENNE("Moyenne"), + HAUTE("Haute"); + + private final String value; + + Priority(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static Priority fromValue(String value) { + return Arrays.stream(values()) + .filter(p -> p.value.equals(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown Priority: " + value)); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 06a1bb8..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=bonsai-api diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..6c9af10 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,25 @@ +server: + port: 8080 + +spring: + datasource: + url: ${DATASOURCE_URL:jdbc:postgresql://localhost:5432/bonsai} + username: ${DATASOURCE_USERNAME:bonsai} + password: ${DATASOURCE_PASSWORD:bonsai} + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + flyway: + enabled: true + security: + oauth2: + resourceserver: + jwt: + jwk-set-uri: ${KEYCLOAK_JWKS_URI:https://auth.goutailler-olivier.com/realms/bonsai/protocol/openid-connect/certs} + +app: + cors: + allowed-origins: + - http://localhost:4200 + - ${CORS_ALLOWED_ORIGIN_PROD:https://bonsai.goutailler-olivier.com} diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..5ffb19d --- /dev/null +++ b/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,27 @@ +CREATE TABLE issues ( + id BIGSERIAL PRIMARY KEY, + type VARCHAR(50) NOT NULL, + assignee VARCHAR(255), + epic VARCHAR(255), + name VARCHAR(255) NOT NULL, + due_date DATE, + description TEXT, + estimated_time DOUBLE PRECISION, + priority VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'draft', + progress INTEGER NOT NULL DEFAULT 0 +); + +-- No FK on depends_on_id: dangling refs allowed (frontend cleans up) +CREATE TABLE issue_depends_on ( + issue_id BIGINT NOT NULL REFERENCES issues (id) ON DELETE CASCADE, + depends_on_id BIGINT NOT NULL +); + +CREATE TABLE comments ( + id BIGSERIAL PRIMARY KEY, + issue_id BIGINT NOT NULL REFERENCES issues (id) ON DELETE CASCADE, + text TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ +); From 0fc87ed4722eaad78e6a85485b7bcc5f0480ae29 Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 24 May 2026 10:07:47 +0200 Subject: [PATCH 04/14] conf env de dev --- Dockerfile.dev | 10 +++++++ docker-compose.dev.yml | 43 ++++++++++++++++++++++++++++++ gradlew | 0 src/main/resources/application.yml | 4 +-- 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 Dockerfile.dev create mode 100644 docker-compose.dev.yml mode change 100644 => 100755 gradlew diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..64e8384 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,10 @@ +FROM eclipse-temurin:25-jdk-alpine +WORKDIR /app + +# Pre-download dependencies (cached layer — rebuilt only if build.gradle changes) +COPY gradlew settings.gradle build.gradle ./ +COPY gradle ./gradle +RUN chmod +x gradlew && ./gradlew dependencies --no-daemon -q + +# Sources are mounted at runtime via docker-compose volume +ENTRYPOINT ["sh", "gradlew", "bootRun", "--no-daemon"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..71a7e48 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,43 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: bonsai + POSTGRES_USER: bonsai + POSTGRES_PASSWORD: bonsai + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U bonsai"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: + context: . + dockerfile: Dockerfile.dev + ports: + - "8080:8080" + volumes: + - ./src:/app/src:z # sources live depuis le host (SELinux :z) + - ./build.gradle:/app/build.gradle:z + - ./settings.gradle:/app/settings.gradle:z + - gradle_home:/root/.gradle # cache Gradle persistant (initialisé depuis l'image) + - gradle_build:/app/build # artefacts de build + environment: + DATASOURCE_URL: jdbc:postgresql://db:5432/bonsai + DATASOURCE_USERNAME: bonsai + DATASOURCE_PASSWORD: bonsai + KEYCLOAK_JWKS_URI: https://auth.goutailler-olivier.com/realms/bonsai/protocol/openid-connect/certs + CORS_ALLOWED_ORIGIN_PROD: https://bonsai.goutailler-olivier.com + depends_on: + db: + condition: service_healthy + +volumes: + postgres_data: + gradle_home: + gradle_build: diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6c9af10..cf85e1c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -20,6 +20,4 @@ spring: app: cors: - allowed-origins: - - http://localhost:4200 - - ${CORS_ALLOWED_ORIGIN_PROD:https://bonsai.goutailler-olivier.com} + allowed-origins: "http://localhost:4200,${CORS_ALLOWED_ORIGIN_PROD:https://bonsai.goutailler-olivier.com}" From ad298fa9e76550eac47bcca44d8e837baf027637 Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 24 May 2026 19:02:46 +0200 Subject: [PATCH 05/14] correction probleme de pipeline --- .claude/settings.local.json | 11 +++++++++++ .gitea/workflows/ci.yml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..53d70fe --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(find /var/home/Gato/IdeaProjects/Infra -name \"docker-compose*\" | head -20)", + "Read(//var/home/Gato/IdeaProjects/Infra/**)", + "Bash(ls /var/home/Gato/IdeaProjects/Infra/)", + "Bash(find /var/home/Gato/IdeaProjects/Bonsai-webapp -name \"*.yml\" -path \"*/.gitea/*\" -o -name \"*.yaml\" -path \"*/.gitea/*\" 2>/dev/null)", + "Read(//var/home/Gato/IdeaProjects/Bonsai-webapp/**)" + ] + } +} diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9842003..84cb0eb 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: uses: https://github.com/actions/setup-java@v4 with: java-version: '25' - distribution: 'temurin' + distribution: 'zulu' cache: 'gradle' - name: Run tests From 2f32987338c4da3d1d37f9d3029628a6d78c45e0 Mon Sep 17 00:00:00 2001 From: Gato Date: Sun, 24 May 2026 19:07:36 +0200 Subject: [PATCH 06/14] =?UTF-8?q?probl=C3=A8me=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 84cb0eb..9842003 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: uses: https://github.com/actions/setup-java@v4 with: java-version: '25' - distribution: 'zulu' + distribution: 'temurin' cache: 'gradle' - name: Run tests From 2bd36357ca2f53aead4da05d627f05b30fee500d Mon Sep 17 00:00:00 2001 From: Gato Date: Mon, 25 May 2026 06:00:14 +0200 Subject: [PATCH 07/14] Correction pb runner Gitea vefrsion java --- .gitea/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9842003..e91b6ca 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -22,8 +22,9 @@ jobs: with: java-version: '25' distribution: 'temurin' + check-latest: true cache: 'gradle' - + - name: Run tests run: ./gradlew test From 4b419be4449d591732e3df5ee4a1aa3399a272ff Mon Sep 17 00:00:00 2001 From: Gato Date: Mon, 25 May 2026 06:04:07 +0200 Subject: [PATCH 08/14] Correction runner pb version java --- .gitea/workflows/ci.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e91b6ca..3cd2033 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -17,13 +17,12 @@ jobs: - name: Checkout uses: https://github.com/actions/checkout@v4 - - name: Setup Java 25 - uses: https://github.com/actions/setup-java@v4 - with: - java-version: '25' - distribution: 'temurin' - check-latest: true - cache: 'gradle' + - name: Install Java 25 + run: | + wget -q https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25%2B... + # ou via SDKMAN + curl -s "https://get.sdkman.io" | bash + sdk install java 25-tem - name: Run tests run: ./gradlew test From 536267849190256a708dbadd82ea44108b721218 Mon Sep 17 00:00:00 2001 From: Gato Date: Mon, 25 May 2026 06:11:27 +0200 Subject: [PATCH 09/14] Changement source download java jdk25 --- .gitea/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3cd2033..5f81be2 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -19,10 +19,10 @@ jobs: - name: Install Java 25 run: | - wget -q https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25%2B... - # ou via SDKMAN - curl -s "https://get.sdkman.io" | bash - sdk install java 25-tem + wget -q https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.3%2B9/OpenJDK25U-jdk_x64_linux_hotspot_25.0.3_9.tar.gz + tar -xzf OpenJDK25U-jdk_x64_linux_hotspot_25.0.3_9.tar.gz + echo "JAVA_HOME=$(pwd)/jdk-25.0.3+9" >> $GITHUB_ENV + echo "$(pwd)/jdk-25.0.3+9/bin" >> $GITHUB_PATH - name: Run tests run: ./gradlew test From 3aa03daaf1750cf9533ba790b4da84fbfbd0e2c2 Mon Sep 17 00:00:00 2001 From: Gato Date: Mon, 25 May 2026 06:14:03 +0200 Subject: [PATCH 10/14] Correction chemin java 25 --- .gitea/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5f81be2..9fa62fb 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -20,9 +20,9 @@ jobs: - name: Install Java 25 run: | wget -q https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.3%2B9/OpenJDK25U-jdk_x64_linux_hotspot_25.0.3_9.tar.gz - tar -xzf OpenJDK25U-jdk_x64_linux_hotspot_25.0.3_9.tar.gz - echo "JAVA_HOME=$(pwd)/jdk-25.0.3+9" >> $GITHUB_ENV - echo "$(pwd)/jdk-25.0.3+9/bin" >> $GITHUB_PATH + tar -xzf OpenJDK25U-jdk_x64_linux_hotspot_25.0.3_9.tar.gz -C /opt + echo "JAVA_HOME=/opt/jdk-25.0.3+9" >> $GITHUB_ENV + echo "/opt/jdk-25.0.3+9/bin" >> $GITHUB_PATH - name: Run tests run: ./gradlew test From ac4061aa8e81c6066ae107b364bc00eca0f950a4 Mon Sep 17 00:00:00 2001 From: Gato Date: Mon, 25 May 2026 06:15:41 +0200 Subject: [PATCH 11/14] Runner jdk 25 - 1 --- .gitea/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9fa62fb..5e5d738 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -19,8 +19,13 @@ jobs: - name: Install Java 25 run: | + uname -m + uname -a + cat /etc/os-release wget -q https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.3%2B9/OpenJDK25U-jdk_x64_linux_hotspot_25.0.3_9.tar.gz tar -xzf OpenJDK25U-jdk_x64_linux_hotspot_25.0.3_9.tar.gz -C /opt + ls /opt/jdk-25.0.3+9/bin/ + /opt/jdk-25.0.3+9/bin/java -version echo "JAVA_HOME=/opt/jdk-25.0.3+9" >> $GITHUB_ENV echo "/opt/jdk-25.0.3+9/bin" >> $GITHUB_PATH From 294c118d1ccca4a587766c1ea9a85277aa248f53 Mon Sep 17 00:00:00 2001 From: Gato Date: Mon, 25 May 2026 06:17:30 +0200 Subject: [PATCH 12/14] Runner jdk 25 - 2 --- .gitea/workflows/ci.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5e5d738..9f7098e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -19,13 +19,8 @@ jobs: - name: Install Java 25 run: | - uname -m - uname -a - cat /etc/os-release - wget -q https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.3%2B9/OpenJDK25U-jdk_x64_linux_hotspot_25.0.3_9.tar.gz - tar -xzf OpenJDK25U-jdk_x64_linux_hotspot_25.0.3_9.tar.gz -C /opt - ls /opt/jdk-25.0.3+9/bin/ - /opt/jdk-25.0.3+9/bin/java -version + wget -q https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.3%2B9/OpenJDK25U-jdk_x64_alpine-linux_hotspot_25.0.3_9.tar.gz + tar -xzf OpenJDK25U-jdk_x64_alpine-linux_hotspot_25.0.3_9.tar.gz -C /opt echo "JAVA_HOME=/opt/jdk-25.0.3+9" >> $GITHUB_ENV echo "/opt/jdk-25.0.3+9/bin" >> $GITHUB_PATH From 9d474bb331ef784a8fdad439e3e8463e97185718 Mon Sep 17 00:00:00 2001 From: Gato Date: Mon, 25 May 2026 06:29:33 +0200 Subject: [PATCH 13/14] correction test --- build.gradle | 1 + src/test/resources/application.yml | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/test/resources/application.yml diff --git a/build.gradle b/build.gradle index 5606bbb..2fe3242 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'com.h2database:h2' } tasks.named('test') { diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..905a301 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,21 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;NON_KEYWORDS=VALUE + username: sa + password: + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + flyway: + enabled: false + security: + oauth2: + resourceserver: + jwt: + jwk-set-uri: http://localhost:9999/realms/test/protocol/openid-connect/certs + +app: + cors: + allowed-origins: "http://localhost:4200" From d460b8f518cacdac714394aab5d30e4c4f3f1a69 Mon Sep 17 00:00:00 2001 From: Gato Date: Mon, 25 May 2026 06:37:40 +0200 Subject: [PATCH 14/14] job sync develop --- .gitea/workflows/sync-develop.yml | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .gitea/workflows/sync-develop.yml diff --git a/.gitea/workflows/sync-develop.yml b/.gitea/workflows/sync-develop.yml new file mode 100644 index 0000000..f70ab3a --- /dev/null +++ b/.gitea/workflows/sync-develop.yml @@ -0,0 +1,34 @@ +name: Sync release into develop + +on: + pull_request: + types: + - closed + branches: + - main + +jobs: + sync-develop: + if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: https://github.com/actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} + + - name: Create PR release → develop + run: | + RELEASE_BRANCH="${{ github.event.pull_request.head.ref }}" + + curl -X POST \ + -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d "{ + \"title\": \"chore: sync ${RELEASE_BRANCH} into develop\", + \"head\": \"${RELEASE_BRANCH}\", + \"base\": \"develop\", + \"body\": \"Synchronisation automatique après merge de ${RELEASE_BRANCH} dans main.\" + }" \ + "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/pulls" \ No newline at end of file