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 new file mode 100644 index 0000000..9f7098e --- /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: 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/ 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/.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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..65b3b7a --- /dev/null +++ b/Dockerfile @@ -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"] 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/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..2fe3242 100644 --- a/build.gradle +++ b/build.gradle @@ -19,10 +19,18 @@ 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' + testRuntimeOnly 'com.h2database:h2' } tasks.named('test') { 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/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/gradlew b/gradlew old mode 100644 new mode 100755 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/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/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/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/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/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/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 new file mode 100644 index 0000000..7f0350e --- /dev/null +++ b/src/main/java/fr/bonsai/api/config/BeanConfig.java @@ -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); + } +} 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/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..cf85e1c --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,23 @@ +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 +); 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"