Compare commits

..

24 Commits

Author SHA1 Message Date
Gato 438fc159fb Merge pull request 'Release/0.0.5' (#6) from release/0.0.5 into main
CI / Tests & couverture (push) Has been cancelled
Reviewed-on: Bonsai/Bonsai-api#6
2026-05-25 18:38:53 +02:00
Gato 51a38b827f créer collection bruno pour test d'api 2026-05-25 18:37:05 +02:00
Gato fc89568b37 Correction problème 404 en prod 2026-05-25 18:03:02 +02:00
Gato 5948e74596 correction pb buil jar 2026-05-25 07:26:00 +02:00
Gato 5761b0e0c4 Ajoute swagger 2026-05-25 07:26:00 +02:00
Gato ee9777132a ajout api version 2026-05-25 07:26:00 +02:00
Gato f40ed22c54 correction pb d'écriture pour merge dev 2026-05-25 06:56:28 +02:00
Gato 78847729f1 Merge pull request 'correction pb sync' (#3) from release/0.0.1 into main
Reviewed-on: Bonsai/Bonsai-api#3
2026-05-25 06:46:15 +02:00
Gato eb8d8fe4f0 correction pb sync 2026-05-25 06:44:33 +02:00
Gato 8aa3abfafd Merge pull request 'Release/0.0.1' (#1) from release/0.0.1 into main
Reviewed-on: Bonsai/Bonsai-api#1
2026-05-25 06:40:20 +02:00
Gato d460b8f518 job sync develop 2026-05-25 06:37:40 +02:00
Gato 9d474bb331 correction test 2026-05-25 06:29:33 +02:00
Gato 294c118d1c Runner jdk 25 - 2 2026-05-25 06:17:30 +02:00
Gato ac4061aa8e Runner jdk 25 - 1 2026-05-25 06:15:41 +02:00
Gato 3aa03daaf1 Correction chemin java 25 2026-05-25 06:14:03 +02:00
Gato 5362678491 Changement source download java jdk25 2026-05-25 06:11:27 +02:00
Gato 4b419be444 Correction runner pb version java 2026-05-25 06:04:07 +02:00
Gato 2bd36357ca Correction pb runner Gitea vefrsion java 2026-05-25 06:00:14 +02:00
Gato 2f32987338 problème CI 2026-05-24 19:07:36 +02:00
Gato ad298fa9e7 correction probleme de pipeline 2026-05-24 19:02:46 +02:00
Gato 0fc87ed472 conf env de dev 2026-05-24 10:07:47 +02:00
Gato e6d06cb82f api issue 2026-05-24 09:27:43 +02:00
Gato a43ad25ee3 config gitea test + release 2026-05-24 07:41:25 +02:00
Gato bc86c57d78 clean arch 2026-05-24 07:37:26 +02:00
50 changed files with 1500 additions and 2 deletions
+11
View File
@@ -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/**)"
]
}
}
+35
View File
@@ -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: Install Java 25
run: |
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
- 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/
+47
View File
@@ -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: Install Java 25
run: |
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
- 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
+40
View File
@@ -0,0 +1,40 @@
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: Install curl
run: apk add --no-cache curl
- name: Create PR main → develop
run: |
RELEASE_BRANCH="${{ github.event.pull_request.head.ref }}"
STATUS=$(curl -s -o /tmp/pr_response.json -w "%{http_code}" -X POST \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"chore: sync ${RELEASE_BRANCH} into develop\",
\"head\": \"main\",
\"base\": \"develop\",
\"body\": \"Synchronisation automatique après merge de ${RELEASE_BRANCH} dans main.\"
}" \
"https://git.goutailler-olivier.com/api/v1/repos/${{ gitea.repository }}/pulls")
echo "POST /pulls → HTTP $STATUS"
cat /tmp/pr_response.json
[ "$STATUS" = "201" ] || exit 1
+13
View File
@@ -0,0 +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 --from=build /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
+10
View File
@@ -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"]
+130
View File
@@ -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/<org>/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 <token>
```
---
## 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)
```
+1
View File
@@ -0,0 +1 @@
.env
+24
View File
@@ -0,0 +1,24 @@
meta {
name: Get Token
type: http
seq: 1
}
post {
url: {{keycloakUrl}}/protocol/openid-connect/token
body: formUrlEncoded
auth: none
}
body:form-urlencoded {
grant_type: password
client_id: {{clientId}}
username: {{username}}
password: {{password}}
}
script:post-response {
if (res.status === 200) {
bru.setEnvVar("accessToken", res.body.access_token);
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"version": "1",
"name": "Bonsai API",
"type": "collection",
"ignore": []
}
+12
View File
@@ -0,0 +1,12 @@
vars {
baseUrl: http://localhost:8080/api
keycloakUrl: https://auth.goutailler-olivier.com/realms/bonsai
clientId: bonsai-webapp
}
vars:secret [
username,
password,
clientSecret,
accessToken
]
+11
View File
@@ -0,0 +1,11 @@
vars {
baseUrl: https://bonsai.goutailler-olivier.com/api
keycloakUrl: https://auth.goutailler-olivier.com/realms/bonsai
clientId: bonsai-webapp
}
vars:secret [
username,
password,
clientSecret,
accessToken
]
+32
View File
@@ -0,0 +1,32 @@
meta {
name: Create Issue
type: http
seq: 2
}
post {
url: {{baseUrl}}/issues
body: json
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
body:json {
{
"type": "Story",
"name": "Nouvelle issue",
"priority": "Moyenne",
"status": "todo",
"progress": 0,
"assignee": null,
"epic": null,
"dueDate": null,
"description": null,
"estimatedTime": null,
"dependsOnIds": [],
"comments": []
}
}
+15
View File
@@ -0,0 +1,15 @@
meta {
name: Delete Issue
type: http
seq: 4
}
delete {
url: {{baseUrl}}/issues/1
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
+15
View File
@@ -0,0 +1,15 @@
meta {
name: Get All Issues
type: http
seq: 1
}
get {
url: {{baseUrl}}/issues
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
+32
View File
@@ -0,0 +1,32 @@
meta {
name: Update Issue
type: http
seq: 3
}
put {
url: {{baseUrl}}/issues/1
body: json
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
body:json {
{
"type": "Story",
"name": "Issue mise à jour",
"priority": "Haute",
"status": "in-progress",
"progress": 50,
"assignee": null,
"epic": null,
"dueDate": "2026-06-01",
"description": null,
"estimatedTime": 3.5,
"dependsOnIds": [],
"comments": []
}
}
+11
View File
@@ -0,0 +1,11 @@
meta {
name: Get Version
type: http
seq: 1
}
get {
url: {{baseUrl}}/version
body: none
auth: none
}
+14 -1
View File
@@ -19,12 +19,25 @@ repositories {
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6'
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'
testRuntimeOnly 'com.h2database:h2'
}
tasks.named('test') {
useJUnitPlatform()
}
springBoot {
buildInfo()
}
+43
View File
@@ -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:
+33
View File
@@ -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:
Vendored Regular → Executable
View File
@@ -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<IssueResponse> 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<Long> nullSafe(List<Long> list) {
return list != null ? list : List.of();
}
private List<Comment> toComments(IssueRequest request) {
if (request.comments() == null) return List.of();
return request.comments().stream().map(dto -> dto.toDomain()).toList();
}
}
@@ -0,0 +1,24 @@
package fr.bonsai.api.adapter.in.web;
import org.springframework.boot.info.BuildProperties;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/version")
public class VersionController {
private final BuildProperties buildProperties;
public VersionController(BuildProperties buildProperties) {
this.buildProperties = buildProperties;
}
@GetMapping
public VersionResponse get() {
return new VersionResponse(buildProperties.getVersion());
}
public record VersionResponse(String version) {}
}
@@ -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
);
}
}
@@ -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<Long> dependsOnIds,
List<CommentDto> comments,
String priority,
String status,
int progress
) {}
@@ -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<Long> dependsOnIds,
List<CommentDto> 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()
);
}
}
@@ -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());
}
}
@@ -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; }
}
@@ -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<Long> dependsOnIds = new ArrayList<>();
@OneToMany(mappedBy = "issue", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("createdAt ASC")
private List<CommentJpaEntity> 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<Long> getDependsOnIds() { return dependsOnIds; }
public void setDependsOnIds(List<Long> dependsOnIds) { this.dependsOnIds = dependsOnIds; }
public List<CommentJpaEntity> 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; }
}
@@ -0,0 +1,6 @@
package fr.bonsai.api.adapter.out.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
public interface IssueJpaRepository extends JpaRepository<IssueJpaEntity, Long> {
}
@@ -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<Comment> 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;
}
}
@@ -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<Issue> findAll() {
return jpaRepository.findAll().stream().map(IssueMapper::toDomain).toList();
}
@Override
@Transactional(readOnly = true)
public Optional<Issue> 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);
}
}
@@ -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<Long> dependsOnIds,
List<Comment> comments,
Priority priority,
IssueStatus status,
int progress
) {}
Issue create(Command command);
}
@@ -0,0 +1,6 @@
package fr.bonsai.api.application.port.in;
public interface DeleteIssueUseCase {
void delete(Long id);
}
@@ -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<Issue> getAll();
Issue getById(Long id);
}
@@ -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<Long> dependsOnIds,
List<Comment> comments,
Priority priority,
IssueStatus status,
int progress
) {}
Issue update(Command command);
}
@@ -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<Issue> findAll();
Optional<Issue> findById(Long id);
Issue save(Issue issue);
void deleteById(Long id);
boolean existsById(Long id);
}
@@ -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<Issue> 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);
}
}
@@ -0,0 +1,22 @@
package fr.bonsai.api.config;
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;
@Configuration
public class BeanConfig {
@Bean
public IssueRepository issueRepository(IssueJpaRepository jpaRepository) {
return new JpaIssueRepositoryAdapter(jpaRepository);
}
@Bean
public IssueService issueService(IssueRepository issueRepository) {
return new IssueService(issueRepository);
}
}
@@ -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<String> 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;
}
}
@@ -0,0 +1,34 @@
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()
.requestMatchers(HttpMethod.GET, "/version").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
);
return http.build();
}
}
@@ -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; }
}
@@ -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<Long> dependsOnIds;
private final List<Comment> 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<Long> dependsOnIds, List<Comment> 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<Long> getDependsOnIds() { return dependsOnIds; }
public List<Comment> getComments() { return comments; }
public Priority getPriority() { return priority; }
public IssueStatus getStatus() { return status; }
public int getProgress() { return progress; }
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -1 +0,0 @@
spring.application.name=bonsai-api
+31
View File
@@ -0,0 +1,31 @@
server:
port: 8080
servlet:
context-path: /api
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}
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
app:
cors:
allowed-origins: "http://localhost:4200,${CORS_ALLOWED_ORIGIN_PROD:https://bonsai.goutailler-olivier.com}"
@@ -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
);
+21
View File
@@ -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"