Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 438fc159fb | |||
| 51a38b827f | |||
| fc89568b37 | |||
| 5948e74596 | |||
| 5761b0e0c4 | |||
| ee9777132a | |||
| f40ed22c54 | |||
| 78847729f1 | |||
| eb8d8fe4f0 | |||
| 8aa3abfafd | |||
| d460b8f518 | |||
| 9d474bb331 | |||
| 294c118d1c | |||
| ac4061aa8e | |||
| 3aa03daaf1 | |||
| 5362678491 | |||
| 4b419be444 | |||
| 2bd36357ca | |||
| 2f32987338 | |||
| ad298fa9e7 | |||
| 0fc87ed472 | |||
| e6d06cb82f | |||
| a43ad25ee3 | |||
| bc86c57d78 |
@@ -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/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
.env
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "Bonsai API",
|
||||
"type": "collection",
|
||||
"ignore": []
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
]
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
}
|
||||
@@ -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}}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Get Version
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/version
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
+14
-1
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
);
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user