Compare commits
2 commits
main
...
implement-
| Author | SHA1 | Date | |
|---|---|---|---|
| 6323f21a9a | |||
| 61248fd16c |
185 changed files with 6801 additions and 126 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
.target
|
||||
**/target
|
||||
README.md
|
||||
TODO.md
|
||||
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
28
.env.example
Normal file
28
.env.example
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
SPRING_PROFILES_ACTIVE=seed
|
||||
|
||||
# Docker Compose defaults for API v1
|
||||
API_V1_VERSION=v1
|
||||
API_V1_PORT=8080
|
||||
API_V1_LOG_LEVEL_ROOT=INFO
|
||||
API_V1_LOG_LEVEL_APP=INFO
|
||||
API_V1_SWAGGER_ENABLED=true
|
||||
API_V1_API_DOCS_ENABLED=true
|
||||
API_V1_API_DOCS_PATH=/api-docs
|
||||
API_V1_SWAGGER_UI_PATH=/swagger-ui.html
|
||||
API_V1_MANAGEMENT_EXPOSURE=*
|
||||
API_V1_MANAGEMENT_HEALTH_DETAILS=always
|
||||
|
||||
# Signature verification (API v1)
|
||||
API_V1_SIGNATURE_ENABLED=false
|
||||
|
||||
# Docker Compose defaults for a future API v2
|
||||
API_V2_VERSION=v2
|
||||
API_V2_PORT=8081
|
||||
API_V2_LOG_LEVEL_ROOT=INFO
|
||||
API_V2_LOG_LEVEL_APP=INFO
|
||||
API_V2_SWAGGER_ENABLED=true
|
||||
API_V2_API_DOCS_ENABLED=true
|
||||
API_V2_API_DOCS_PATH=/api-docs
|
||||
API_V2_SWAGGER_UI_PATH=/swagger-ui.html
|
||||
API_V2_MANAGEMENT_EXPOSURE=*
|
||||
API_V2_MANAGEMENT_HEALTH_DETAILS=always
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -66,3 +66,6 @@ buildNumber.properties
|
|||
.ionide
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/maven,java,visualstudiocode
|
||||
|
||||
# Local Docker Compose environment overrides
|
||||
.env
|
||||
|
|
|
|||
10
.mvn/jvm.config
Normal file
10
.mvn/jvm.config
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
|
||||
--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
|
||||
--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED
|
||||
13
.vscode/extensions.json
vendored
Normal file
13
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"vscjava.vscode-java-pack",
|
||||
"shengchen.vscode-checkstyle",
|
||||
"editorconfig.editorconfig",
|
||||
"usernamehw.errorlens",
|
||||
"vmware.vscode-boot-dev-pack",
|
||||
"vscjava.vscode-java-test",
|
||||
"redhat.vscode-xml",
|
||||
"redhat.vscode-yaml",
|
||||
"sonarsource.sonarlint-vscode"
|
||||
]
|
||||
}
|
||||
24
.vscode/launch.json
vendored
24
.vscode/launch.json
vendored
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"configurations": [
|
||||
{
|
||||
"type": "java",
|
||||
"name": "Spring Boot-BackendapiApplication<backendapi>",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"mainClass": "com.ldc.arcadiagenesis.backendapi.BackendapiApplication",
|
||||
"projectName": "backendapi",
|
||||
"args": "",
|
||||
"envFile": "${workspaceFolder}/.env"
|
||||
}
|
||||
]
|
||||
"configurations": [
|
||||
{
|
||||
"type": "java",
|
||||
"name": "Spring Boot-BackendapiApplication<backendapi-api-v1>",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"mainClass": "com.ldc.arcadiagenesis.backendapi.application.apiv1.BackendapiApplication",
|
||||
"projectName": "backendapi-api-v1",
|
||||
"args": "",
|
||||
"envFile": "${workspaceFolder}/.env"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
53
.vscode/settings.json
vendored
53
.vscode/settings.json
vendored
|
|
@ -1,23 +1,34 @@
|
|||
{
|
||||
"java.compile.nullAnalysis.mode": "automatic",
|
||||
"workbench.colorCustomizations": {
|
||||
"activityBar.activeBackground": "#73b8de",
|
||||
"activityBar.background": "#73b8de",
|
||||
"activityBar.foreground": "#15202b",
|
||||
"activityBar.inactiveForeground": "#15202b99",
|
||||
"activityBarBadge.background": "#ce3196",
|
||||
"activityBarBadge.foreground": "#e7e7e7",
|
||||
"commandCenter.border": "#15202b99",
|
||||
"sash.hoverBorder": "#73b8de",
|
||||
"statusBar.background": "#4aa3d4",
|
||||
"statusBar.foreground": "#15202b",
|
||||
"statusBarItem.hoverBackground": "#2d8abe",
|
||||
"statusBarItem.remoteBackground": "#4aa3d4",
|
||||
"statusBarItem.remoteForeground": "#15202b",
|
||||
"titleBar.activeBackground": "#4aa3d4",
|
||||
"titleBar.activeForeground": "#15202b",
|
||||
"titleBar.inactiveBackground": "#4aa3d499",
|
||||
"titleBar.inactiveForeground": "#15202b99"
|
||||
},
|
||||
"peacock.color": "#4aa3d4"
|
||||
"workbench.colorCustomizations": {
|
||||
"activityBar.activeBackground": "#73b8de",
|
||||
"activityBar.background": "#73b8de",
|
||||
"activityBar.foreground": "#15202b",
|
||||
"activityBar.inactiveForeground": "#15202b99",
|
||||
"activityBarBadge.background": "#ce3196",
|
||||
"activityBarBadge.foreground": "#e7e7e7",
|
||||
"commandCenter.border": "#15202b99",
|
||||
"sash.hoverBorder": "#73b8de",
|
||||
"statusBar.background": "#4aa3d4",
|
||||
"statusBar.foreground": "#15202b",
|
||||
"statusBarItem.hoverBackground": "#2d8abe",
|
||||
"statusBarItem.remoteBackground": "#4aa3d4",
|
||||
"statusBarItem.remoteForeground": "#15202b",
|
||||
"titleBar.activeBackground": "#4aa3d4",
|
||||
"titleBar.activeForeground": "#15202b",
|
||||
"titleBar.inactiveBackground": "#4aa3d499",
|
||||
"titleBar.inactiveForeground": "#15202b99"
|
||||
},
|
||||
"peacock.color": "#4aa3d4",
|
||||
"java.configuration.updateBuildConfiguration": "interactive",
|
||||
"java.dependency.packagePresentation": "hierarchical",
|
||||
"java.compile.nullAnalysis.mode": "automatic",
|
||||
"java.checkstyle.configuration": "./checkstyle/checkstyle.xml",
|
||||
"java.checkstyle.version": "13.4.0",
|
||||
"[java]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "josevseb.google-java-format-for-vs-code",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "never"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG JAVA_VERSION=25
|
||||
ARG API_VERSION=v1
|
||||
|
||||
FROM eclipse-temurin:${JAVA_VERSION}-jdk AS build
|
||||
ARG API_VERSION
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY .mvn/ .mvn/
|
||||
COPY mvnw mvnw
|
||||
COPY pom.xml pom.xml
|
||||
COPY checkstyle/ checkstyle/
|
||||
COPY config/ config/
|
||||
COPY src/ src/
|
||||
|
||||
RUN chmod +x mvnw
|
||||
RUN ./mvnw -pl "src/api/${API_VERSION}" -am package -DskipTests
|
||||
RUN JAR_PATH="$(find "src/api/${API_VERSION}/target" -maxdepth 1 -type f -name '*.jar' ! -name 'original-*.jar' | head -n 1)" \
|
||||
&& test -n "$JAR_PATH" \
|
||||
&& cp "$JAR_PATH" /workspace/app.jar
|
||||
|
||||
FROM eclipse-temurin:${JAVA_VERSION}-jre AS runtime
|
||||
ARG API_VERSION=v1
|
||||
ARG APP_PORT=8080
|
||||
ENV API_VERSION=${API_VERSION}
|
||||
ENV APP_PORT=${APP_PORT}
|
||||
ENV SERVER_PORT=${APP_PORT}
|
||||
ENV SERVER_SERVLET_CONTEXT_PATH=/api/${API_VERSION}
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /workspace/app.jar /app/app.jar
|
||||
|
||||
EXPOSE ${APP_PORT}
|
||||
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
292
README.md
292
README.md
|
|
@ -1 +1,293 @@
|
|||
# Arcadia Genesis Demo
|
||||
|
||||
API backend Spring Boot (Maven) avec qualite integree : Spotless, Checkstyle, Error Prone, NullAway et JaCoCo.
|
||||
|
||||
## Architecture multi-module
|
||||
|
||||
Le projet est organise en 5 modules Maven sous src/ :
|
||||
|
||||
```text
|
||||
src/
|
||||
common/ -> backendapi-common
|
||||
domain/ -> backendapi-domain
|
||||
infrastructure/ -> backendapi-infrastructure
|
||||
application/ -> backendapi-application-core
|
||||
api/
|
||||
v1/ -> backendapi-api-v1
|
||||
```
|
||||
|
||||
Package racine : com.ldc.arcadiagenesis.backendapi
|
||||
|
||||
## Separation des responsabilites
|
||||
|
||||
- application-core : logique applicative reutilisable, sans dependance Spring.
|
||||
- infrastructure : adaptateurs techniques en Java pur (pas d'annotations Spring).
|
||||
- api/v1 : bootstrap Spring Boot (controllers, securite, wiring des beans, events Spring).
|
||||
|
||||
Spring est confine au module api/v1. Le wiring des beans infra/core est centralise dans :
|
||||
|
||||
- src/api/v1/src/main/java/com/ldc/arcadiagenesis/backendapi/application/apiv1/config/InfrastructureConfiguration.java
|
||||
- src/api/v1/src/main/java/com/ldc/arcadiagenesis/backendapi/application/apiv1/config/ApplicationCoreConfiguration.java
|
||||
|
||||
## Dependances entre modules
|
||||
|
||||
```text
|
||||
backendapi-api-v1 -> backendapi-application-core, backendapi-infrastructure,
|
||||
backendapi-domain, backendapi-common
|
||||
backendapi-application-core -> backendapi-domain, backendapi-common
|
||||
backendapi-infrastructure -> backendapi-domain, backendapi-common
|
||||
backendapi-domain -> backendapi-common
|
||||
```
|
||||
|
||||
## Versionnement de l'API
|
||||
|
||||
Le versionnement se fait par module bootstrap sous src/api/.
|
||||
|
||||
- v1 actuel : src/api/v1
|
||||
- futur v2 : src/api/v2 (a creer)
|
||||
|
||||
Chaque version peut exposer ses routes/configs propres, tout en reutilisant application-core et infrastructure.
|
||||
|
||||
## Prerequis
|
||||
|
||||
- Java 25
|
||||
- Maven Wrapper (./mvnw)
|
||||
|
||||
## Commandes principales
|
||||
|
||||
### Build rapide
|
||||
|
||||
```bash
|
||||
./mvnw clean compile
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
./mvnw test
|
||||
```
|
||||
|
||||
### Verification complete (CI locale)
|
||||
|
||||
Inclut : compile (Error Prone + NullAway), tests, Spotless check, Checkstyle, JaCoCo.
|
||||
|
||||
```bash
|
||||
./mvnw clean verify
|
||||
```
|
||||
|
||||
## Qualite du code
|
||||
|
||||
### Spotless
|
||||
|
||||
Verifier :
|
||||
|
||||
```bash
|
||||
./mvnw spotless:check
|
||||
```
|
||||
|
||||
Appliquer :
|
||||
|
||||
```bash
|
||||
./mvnw spotless:apply
|
||||
```
|
||||
|
||||
### Checkstyle (Google style)
|
||||
|
||||
```bash
|
||||
./mvnw checkstyle:check
|
||||
```
|
||||
|
||||
Configuration : checkstyle/checkstyle.xml
|
||||
|
||||
### Error Prone + NullAway
|
||||
|
||||
Executes pendant compile :
|
||||
|
||||
```bash
|
||||
./mvnw compile
|
||||
```
|
||||
|
||||
## Couverture (JaCoCo)
|
||||
|
||||
La phase verify genere les rapports et impose un seuil minimum de couverture de 80%.
|
||||
|
||||
Rapports du module api/v1 :
|
||||
|
||||
- HTML : src/api/v1/target/site/jacoco/index.html
|
||||
- XML : src/api/v1/target/site/jacoco/jacoco.xml
|
||||
|
||||
## Lancer l'application v1
|
||||
|
||||
Le module api/v1 depend des autres modules du mono-repo.
|
||||
|
||||
Il faut donc d'abord installer les artefacts locaux depuis la racine du projet :
|
||||
|
||||
```bash
|
||||
./mvnw clean install -DskipTests
|
||||
```
|
||||
|
||||
Puis lancer le module v1 :
|
||||
|
||||
```bash
|
||||
./mvnw -pl src/api/v1 spring-boot:run
|
||||
```
|
||||
|
||||
Pourquoi :
|
||||
|
||||
- `spring-boot:run` est execute uniquement sur le module `src/api/v1`
|
||||
- ses dependances inter-modules (`backendapi-common`, `backendapi-domain`, `backendapi-infrastructure`, `backendapi-application-core`) doivent donc deja exister dans le depot Maven local
|
||||
|
||||
## OpenAPI / Swagger
|
||||
|
||||
La documentation OpenAPI de l'API v1 est exposee par springdoc.
|
||||
|
||||
Configuration actuelle :
|
||||
|
||||
- context path applicatif : /api/v1
|
||||
- endpoint OpenAPI JSON : /api-docs
|
||||
- interface Swagger UI : /swagger-ui/index.html
|
||||
|
||||
Une fois l'application lancee, tu peux acceder a :
|
||||
|
||||
- Swagger UI : http://localhost:8080/api/v1/swagger-ui/index.html
|
||||
- OpenAPI JSON : http://localhost:8080/api/v1/api-docs
|
||||
|
||||
Remarque :
|
||||
|
||||
- le prefixe /api/v1 vient de la propriete server.servlet.context-path
|
||||
- les chemins /api-docs et /swagger-ui proviennent de la configuration springdoc dans src/api/v1/src/main/resources/application.properties
|
||||
|
||||
## Docker
|
||||
|
||||
Un Dockerfile unique permet de builder une version d'API donnee et de definir le port directement au build.
|
||||
|
||||
Arguments supportes :
|
||||
|
||||
- API_VERSION : version du module a builder sous src/api/ (exemple : v1, futur v2)
|
||||
- APP_PORT : port HTTP expose par le conteneur
|
||||
|
||||
### Build de l'image
|
||||
|
||||
Exemple pour v1 sur le port 8080 :
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg API_VERSION=v1 \
|
||||
--build-arg APP_PORT=8080 \
|
||||
-t arcadia-api:v1 .
|
||||
```
|
||||
|
||||
Exemple pour une future v2 sur le port 8081 :
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg API_VERSION=v2 \
|
||||
--build-arg APP_PORT=8081 \
|
||||
-t arcadia-api:v2 .
|
||||
```
|
||||
|
||||
### Lancer le conteneur
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8080:8080 arcadia-api:v1
|
||||
```
|
||||
|
||||
Si l'image a ete build avec APP_PORT=8081 :
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8081:8081 arcadia-api:v2
|
||||
```
|
||||
|
||||
### Effet des arguments Docker
|
||||
|
||||
- API_VERSION selectionne le module Maven src/api/<version> a packager
|
||||
- APP_PORT surcharge server.port dans Spring Boot
|
||||
- le conteneur fixe aussi automatiquement le context-path a /api/<version>
|
||||
|
||||
Exemple pour v1 :
|
||||
|
||||
- application : http://localhost:8080/api/v1
|
||||
- Swagger UI : http://localhost:8080/api/v1/swagger-ui/index.html
|
||||
- OpenAPI JSON : http://localhost:8080/api/v1/api-docs
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Le fichier docker-compose.yml permet de piloter les differentes versions d'API et leurs parametres via des variables d'environnement.
|
||||
|
||||
Un fichier d'exemple est fourni : .env.example
|
||||
|
||||
Tu peux t'en servir comme base pour tes variables locales :
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up --build api-v1
|
||||
```
|
||||
|
||||
Service disponible par defaut :
|
||||
|
||||
- api-v1
|
||||
|
||||
Service prepare pour une future version :
|
||||
|
||||
- api-v2, active uniquement avec le profile compose `v2`
|
||||
|
||||
### Lancer v1
|
||||
|
||||
```bash
|
||||
docker compose up --build api-v1
|
||||
```
|
||||
|
||||
### Lancer v2
|
||||
|
||||
Quand le module src/api/v2 existera :
|
||||
|
||||
```bash
|
||||
docker compose --profile v2 up --build api-v2
|
||||
```
|
||||
|
||||
### Parametres configurables
|
||||
|
||||
Pour v1 :
|
||||
|
||||
- API_V1_VERSION : version build du module API, par defaut `v1`
|
||||
- API_V1_PORT : port HTTP du conteneur, par defaut `8080`
|
||||
- API_V1_LOG_LEVEL_ROOT : niveau de log racine, par defaut `INFO`
|
||||
- API_V1_LOG_LEVEL_APP : niveau de log applicatif `com.openapi.demo`, par defaut `INFO`
|
||||
- API_V1_SWAGGER_ENABLED : active Swagger UI, par defaut `true`
|
||||
- API_V1_API_DOCS_ENABLED : active l'endpoint OpenAPI JSON, par defaut `true`
|
||||
- API_V1_API_DOCS_PATH : chemin des api-docs, par defaut `/api-docs`
|
||||
- API_V1_SWAGGER_UI_PATH : chemin Swagger UI, par defaut `/swagger-ui.html`
|
||||
- API_V1_MANAGEMENT_EXPOSURE : endpoints actuator exposes, par defaut `*`
|
||||
- API_V1_MANAGEMENT_HEALTH_DETAILS : niveau de details health actuator, par defaut `always`
|
||||
|
||||
Les memes variables existent pour v2 avec le prefixe `API_V2_`.
|
||||
|
||||
### Exemple de lancement avec parametres personnalises
|
||||
|
||||
```bash
|
||||
API_V1_PORT=8090 \
|
||||
API_V1_LOG_LEVEL_ROOT=DEBUG \
|
||||
API_V1_SWAGGER_ENABLED=true \
|
||||
API_V1_API_DOCS_ENABLED=true \
|
||||
docker compose up --build api-v1
|
||||
```
|
||||
|
||||
Avec cette configuration :
|
||||
|
||||
- application : http://localhost:8090/api/v1
|
||||
- Swagger UI : http://localhost:8090/api/v1/swagger-ui/index.html
|
||||
- OpenAPI JSON : http://localhost:8090/api/v1/api-docs
|
||||
|
||||
### Comment ca fonctionne
|
||||
|
||||
- le Dockerfile build le module `src/api/<version>` choisi
|
||||
- docker-compose passe les variables d'environnement a Spring Boot
|
||||
- application.properties lit ces variables avec des valeurs par defaut pour le port, les logs, OpenAPI et Actuator
|
||||
|
||||
## Etat des controles verifies localement
|
||||
|
||||
Les commandes suivantes ont ete executees avec succes (exit code 0) :
|
||||
|
||||
- ./mvnw spotless:check -q
|
||||
- ./mvnw checkstyle:check -q
|
||||
- ./mvnw compile -q
|
||||
|
|
|
|||
98
TODO.md
Normal file
98
TODO.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Plan d'architecture - modules indépendants de Spring Boot
|
||||
|
||||
## Problèmes actuels
|
||||
|
||||
| Fichier | Problème |
|
||||
|---|---|
|
||||
| `application/v1/.../ddd/repository/RepositoryMethodInterceptor.java` | Étend `MethodInterceptor` (AOP Alliance) → couplage AOP dans le core |
|
||||
| `application/v1/.../dependencies/AuditAndEventInterceptor.java` | Utilise `TransactionSynchronizationManager` Spring dans la logique métier |
|
||||
| `infrastructure/.../annotations/Directory.java` | Annotation qui étend `@Component` Spring → lie l'infra au runtime Spring |
|
||||
| `infrastructure/.../adapters/directory/InMemoryEmployeeDirectory.java` | Utilise `@Qualifier` Spring → câblage Spring dans l'adaptateur |
|
||||
| `application/v1/.../events/SpringDomainEventPublisher.java` | ✅ Déjà un adaptateur Spring au bon endroit |
|
||||
|
||||
---
|
||||
|
||||
## Règle de validation
|
||||
|
||||
> Si `mvn dependency:analyze` sur `application` ou `infrastructure` remonte une dépendance vers `org.springframework`, c'est une régression.
|
||||
|
||||
---
|
||||
|
||||
## Vue cible des dépendances
|
||||
|
||||
```
|
||||
common → (rien)
|
||||
domain → common
|
||||
infrastructure → common, domain ← plus de Spring
|
||||
application → common, domain ← plus de Spring
|
||||
application/v1 → application, infrastructure, Spring Boot
|
||||
application/v2 → application, infrastructure, Spring Boot (version libre)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 - Créer le module `application`
|
||||
|
||||
But : avoir un module Java pur, sans Spring, partageable par v1, v2.
|
||||
|
||||
- [ ] Créer `src/application/pom.xml` avec parent `backendapi`
|
||||
- [ ] Dépendances autorisées : `backendapi-common`, `backendapi-domain`, `jspecify` - **pas de Spring**
|
||||
- [ ] Ajouter `<module>src/application</module>` dans le `pom.xml` racine
|
||||
- [ ] Ajouter `backendapi-application` dans le `<dependencyManagement>` du `pom.xml` racine
|
||||
- [ ] Y déplacer `application/v1/.../ddd/` et `application/v1/.../dependencies/` (code pur uniquement)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 - Extraire la logique d'audit/événements vers le core pur
|
||||
|
||||
But : supprimer le couplage à `TransactionSynchronizationManager` et à `MethodInterceptor`.
|
||||
|
||||
### 2a - Port `AfterCommitExecutor` dans `application`
|
||||
- [ ] Créer `AfterCommitExecutor.java` : interface pure avec `execute(Runnable action)`
|
||||
|
||||
### 2b - Décorateur pur à la place de l'intercepteur AOP
|
||||
- [ ] Supprimer `RepositoryMethodInterceptor.java` (dépend d'AOP Alliance)
|
||||
- [ ] Créer `AuditedRepository<T>` dans `application` : décorateur générique autour de `Repository<T>`, reçoit `DomainEventPublisher` et `AfterCommitExecutor` par constructeur - aucune annotation
|
||||
|
||||
### 2c - Refactorer `AuditAndEventInterceptor`
|
||||
- [ ] Supprimer `AuditAndEventInterceptor.java` de `application/v1`
|
||||
- [ ] Créer `SpringAfterCommitExecutor.java` dans `application/v1` : implémente `AfterCommitExecutor` via `TransactionSynchronizationManager`
|
||||
- [ ] Le câblage (proxy ou `new`) reste dans une `@Configuration` de `application/v1`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 - Découpler le module `infrastructure` de Spring
|
||||
|
||||
But : les adaptateurs infra ne portent plus d'annotations Spring.
|
||||
|
||||
### 3a - Annotation `@Directory`
|
||||
- [ ] Supprimer `@Component` et `@AliasFor` de `Directory.java`
|
||||
- [ ] En faire une simple annotation de marqueur (documentation sémantique uniquement)
|
||||
|
||||
### 3b - Adaptateurs infra
|
||||
- [ ] Supprimer `@Directory`, `@Repository`, `@Qualifier` de tous les adaptateurs sous `adapters/`
|
||||
- [ ] Les classes deviennent des POJOs construits par constructeur
|
||||
|
||||
### 3c - Déclaration des beans Spring
|
||||
- [ ] Créer `InfrastructureConfiguration.java` dans `application/v1` qui instancie et expose les beans infra
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 - Mettre à jour `application/v1`
|
||||
|
||||
But : v1 est le seul module à connaître Spring Boot. Il fournit tout le câblage.
|
||||
|
||||
- [ ] Ajouter la dépendance `backendapi-application` dans `application/v1/pom.xml`
|
||||
- [ ] Créer `InfrastructureConfiguration.java` - déclare les beans infra
|
||||
- [ ] Créer `ApplicationCoreConfiguration.java` - câble `AuditedRepository`, `SpringAfterCommitExecutor`, `SpringDomainEventPublisher`
|
||||
- [ ] `SpringDomainEventPublisher.java` reste ici tel quel ✅
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 - Préparer le futur module `v2`
|
||||
|
||||
But : valider que la structure tient la promesse d'isolation.
|
||||
|
||||
- [ ] Créer `src/application/v2/pom.xml` avec dépendances `application` + `infrastructure` + Spring Boot (même version ou autre)
|
||||
- [ ] Ne doit pas connaître `application/v1`
|
||||
- [ ] Fournit ses propres `*Configuration.java`
|
||||
11
checkstyle/checkstyle-suppressions.xml.inlined
Normal file
11
checkstyle/checkstyle-suppressions.xml.inlined
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0"?>
|
||||
<!DOCTYPE suppressions PUBLIC
|
||||
"-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
|
||||
"https://checkstyle.org/dtds/suppressions_1_2.dtd">
|
||||
<suppressions>
|
||||
<suppress checks="MissingJavadoc*" files=".*[/\\]*.java$"/>
|
||||
|
||||
<suppress checks="ImportOrder" files=".*[/\\]*.java$"/>
|
||||
<suppress checks="CatchParameterNameCheck" files=".*[/\\]*.java$"/>
|
||||
|
||||
</suppressions>
|
||||
450
checkstyle/checkstyle.xml
Normal file
450
checkstyle/checkstyle.xml
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
<?xml version="1.0"?>
|
||||
<!DOCTYPE module PUBLIC
|
||||
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
|
||||
"https://checkstyle.org/dtds/configuration_1_3.dtd">
|
||||
|
||||
<!--
|
||||
Checkstyle configuration that checks the Google coding conventions from Google Java Style
|
||||
that can be found at https://google.github.io/styleguide/javaguide.html
|
||||
|
||||
Checkstyle is very configurable. Be sure to read the documentation at
|
||||
http://checkstyle.org (or in your downloaded distribution).
|
||||
|
||||
To completely disable a check, just comment it out or delete it from the file.
|
||||
To suppress certain violations please review suppression filters.
|
||||
|
||||
Authors: Max Vetrenko, Mauryan Kansara, Ruslan Diachenko, Roman Ivanov.
|
||||
-->
|
||||
|
||||
<module name="Checker">
|
||||
|
||||
<property name="charset" value="UTF-8"/>
|
||||
|
||||
<property name="severity" value="${org.checkstyle.google.severity}" default="warning"/>
|
||||
|
||||
<property name="fileExtensions" value="java, properties, xml"/>
|
||||
<!-- Excludes all 'module-info.java' files -->
|
||||
<!-- See https://checkstyle.org/filefilters/index.html -->
|
||||
<module name="BeforeExecutionExclusionFileFilter">
|
||||
<property name="fileNamePattern" value="module\-info\.java$"/>
|
||||
</module>
|
||||
|
||||
<module name="SuppressWarningsFilter"/>
|
||||
|
||||
<!-- https://checkstyle.org/filters/suppressionfilter.html -->
|
||||
<!-- <module name="SuppressionFilter">
|
||||
<property name="file" value="${org.checkstyle.google.suppressionfilter.config}" default="checkstyle-suppressions.xml" />
|
||||
<property name="optional" value="true"/>
|
||||
</module> -->
|
||||
|
||||
<!-- Inline suppressions (equivalent to checkstyle-suppressions.xml) -->
|
||||
<module name="SuppressionSingleFilter">
|
||||
<property name="checks" value="MissingJavadoc.*"/>
|
||||
</module>
|
||||
<module name="SuppressionSingleFilter">
|
||||
<property name="checks" value="ImportOrder|CustomImportOrder"/>
|
||||
</module>
|
||||
<module name="SuppressionSingleFilter">
|
||||
<property name="checks" value="CatchParameterName"/>
|
||||
</module>
|
||||
|
||||
<!-- https://checkstyle.org/filters/suppresswithnearbytextfilter.html -->
|
||||
<module name="SuppressWithNearbyTextFilter">
|
||||
<property name="nearbyTextPattern" value="CHECKSTYLE.SUPPRESS\: (\w+) for ([+-]\d+) lines"/>
|
||||
<property name="checkPattern" value="$1"/>
|
||||
<property name="lineRange" value="$2"/>
|
||||
</module>
|
||||
|
||||
<!-- Checks for whitespace -->
|
||||
<!-- See http://checkstyle.org/checks/whitespace/index.html -->
|
||||
<module name="FileTabCharacter">
|
||||
<property name="eachLine" value="true"/>
|
||||
</module>
|
||||
|
||||
<module name="LineLength">
|
||||
<property name="fileExtensions" value="java"/>
|
||||
<property name="max" value="100"/>
|
||||
<property name="ignorePattern" value="^package.*|^import.*|href\s*=\s*"[^"]*"|http://|https://|ftp://"/>
|
||||
</module>
|
||||
<!-- Suppression to prevent LineLength Check from flagging lines in Text-blocks -->
|
||||
<module name="SuppressWithPlainTextCommentFilter">
|
||||
<property name="checkFormat" value="LineLength"/>
|
||||
<property name="offCommentFormat" value='^.*"""\s*$'/>
|
||||
<property name="onCommentFormat" value='^\s*"""\s*(?:[,;]|.+)$'/>
|
||||
</module>
|
||||
<module name="SuppressWithPlainTextCommentFilter">
|
||||
<property name="checkFormat" value="IndentationCheck"/>
|
||||
<property name="offCommentFormat" value='^""".?'/>
|
||||
<property name="onCommentFormat" value='.'/>
|
||||
</module>
|
||||
<module name="TreeWalker">
|
||||
<module name="OuterTypeFilename"/>
|
||||
<module name="MatchXpath">
|
||||
<property name="id" value="singleLineCommentStartWithSpace"/>
|
||||
<property name="query" value="//SINGLE_LINE_COMMENT[./COMMENT_CONTENT[not(starts-with(@text, ' '))
|
||||
and not(starts-with(@text, '/'))
|
||||
and not(@text = '\n') and not(ends-with(@text, '//\n'))]]"/>
|
||||
<message key="matchxpath.match" value="''//'' must be followed by a whitespace."/>
|
||||
</module>
|
||||
<module name="IllegalTokenText">
|
||||
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL, TEXT_BLOCK_CONTENT"/>
|
||||
<property name="format" value="\\u00(08|09|0(a|A)|0(c|C)|0(d|D)|20|22|27|5(C|c))|\\(0(10|11|12|14|15|40|42|47)|134)"/>
|
||||
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
|
||||
</module>
|
||||
<module name="AvoidEscapedUnicodeCharacters">
|
||||
<property name="allowEscapesForControlCharacters" value="true"/>
|
||||
<property name="allowByTailComment" value="true"/>
|
||||
<property name="allowNonPrintableEscapes" value="true"/>
|
||||
</module>
|
||||
<module name="AvoidStarImport"/>
|
||||
<module name="OneTopLevelClass"/>
|
||||
<module name="NoLineWrap">
|
||||
<property name="tokens" value="PACKAGE_DEF, IMPORT, STATIC_IMPORT"/>
|
||||
</module>
|
||||
<module name="NeedBraces">
|
||||
<property name="tokens" value="LITERAL_DO, LITERAL_ELSE, LITERAL_FOR, LITERAL_IF, LITERAL_WHILE"/>
|
||||
</module>
|
||||
<module name="LeftCurly">
|
||||
<property name="id" value="LeftCurlyEol"/>
|
||||
<property name="tokens" value="ANNOTATION_DEF, CLASS_DEF, CTOR_DEF, ENUM_CONSTANT_DEF, ENUM_DEF,
|
||||
INTERFACE_DEF, LAMBDA, LITERAL_CATCH,
|
||||
LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY, LITERAL_FOR, LITERAL_IF,
|
||||
LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_TRY, LITERAL_WHILE, METHOD_DEF,
|
||||
OBJBLOCK, STATIC_INIT, RECORD_DEF, COMPACT_CTOR_DEF, SWITCH_RULE"/>
|
||||
</module>
|
||||
<module name="LeftCurly">
|
||||
<property name="id" value="LeftCurlyNl"/>
|
||||
<property name="option" value="nl"/>
|
||||
<property name="tokens" value="LITERAL_CASE, LITERAL_DEFAULT"/>
|
||||
</module>
|
||||
<module name="SuppressionXpathSingleFilter">
|
||||
<!-- LITERAL_CASE, LITERAL_DEFAULT are reused in SWITCH_RULE -->
|
||||
<property name="id" value="LeftCurlyNl"/>
|
||||
<property name="query" value="//SWITCH_RULE/SLIST"/>
|
||||
</module>
|
||||
<module name="RightCurly">
|
||||
<property name="id" value="RightCurlySame"/>
|
||||
<property name="tokens" value="LITERAL_TRY, LITERAL_CATCH, LITERAL_IF, LITERAL_ELSE,
|
||||
LITERAL_DO"/>
|
||||
</module>
|
||||
<module name="SuppressionXpathSingleFilter">
|
||||
<property name="id" value="RightCurlySame"/>
|
||||
<property name="query" value="//RCURLY[parent::SLIST[parent::LITERAL_CATCH
|
||||
and not(parent::LITERAL_CATCH/following-sibling::*)]]"/>
|
||||
</module>
|
||||
<module name="RightCurly">
|
||||
<property name="id" value="RightCurlyAlone"/>
|
||||
<property name="option" value="alone"/>
|
||||
<property name="tokens" value="CLASS_DEF, METHOD_DEF, CTOR_DEF, LITERAL_FOR, LITERAL_WHILE, STATIC_INIT,
|
||||
INSTANCE_INIT, ANNOTATION_DEF, ENUM_DEF, INTERFACE_DEF, RECORD_DEF,
|
||||
COMPACT_CTOR_DEF, LITERAL_SWITCH, LITERAL_CASE, LITERAL_FINALLY,
|
||||
LITERAL_CATCH"/>
|
||||
</module>
|
||||
<module name="SuppressionXpathSingleFilter">
|
||||
<!-- suppression is required till https://github.com/checkstyle/checkstyle/issues/7541 -->
|
||||
<property name="id" value="RightCurlyAlone"/>
|
||||
<property name="query" value="//RCURLY[parent::SLIST[count(./*)=1
|
||||
and not(parent::LITERAL_CATCH)]
|
||||
or (preceding-sibling::*[last()][self::LCURLY]
|
||||
and not(parent::SLIST/parent::LITERAL_CATCH))
|
||||
or (parent::SLIST/parent::LITERAL_CATCH
|
||||
and parent::SLIST/parent::LITERAL_CATCH/following-sibling::*)]"/>
|
||||
</module>
|
||||
<module name="WhitespaceAfter">
|
||||
<property name="tokens" value="COMMA, SEMI, TYPECAST, ELLIPSIS, LITERAL_YIELD, LITERAL_CASE, ANNOTATIONS"/>
|
||||
</module>
|
||||
<module name="WhitespaceAround">
|
||||
<property name="allowEmptyConstructors" value="true"/>
|
||||
<property name="allowEmptyLambdas" value="true"/>
|
||||
<property name="allowEmptyMethods" value="true"/>
|
||||
<property name="allowEmptyTypes" value="true"/>
|
||||
<property name="allowEmptyLoops" value="true"/>
|
||||
<property name="allowEmptySwitchBlockStatements" value="true"/>
|
||||
<property name="ignoreEnhancedForColon" value="false"/>
|
||||
<property name="tokens" value="ASSIGN, BAND, BAND_ASSIGN, BOR, BOR_ASSIGN, BSR, BSR_ASSIGN, BXOR,
|
||||
BXOR_ASSIGN, COLON, DIV, DIV_ASSIGN, DO_WHILE, EQUAL, GE, GT, LAMBDA, LAND,
|
||||
LCURLY, LE, LITERAL_CATCH, LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY,
|
||||
LITERAL_FOR, LITERAL_IF, LITERAL_RETURN, LITERAL_SWITCH, LITERAL_SYNCHRONIZED,
|
||||
LITERAL_TRY, LITERAL_WHILE, LOR, LT, MINUS, MINUS_ASSIGN, MOD, MOD_ASSIGN,
|
||||
NOT_EQUAL, PLUS, PLUS_ASSIGN, QUESTION, RCURLY, SL, SLIST, SL_ASSIGN, SR,
|
||||
SR_ASSIGN, STAR, STAR_ASSIGN, LITERAL_ASSERT,
|
||||
TYPE_EXTENSION_AND, LITERAL_WHEN"/>
|
||||
<message key="ws.notFollowed" value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks
|
||||
may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
|
||||
<message key="ws.notPreceded" value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
|
||||
</module>
|
||||
<module name="SuppressionXpathSingleFilter">
|
||||
<property name="checks" value="WhitespaceAround"/>
|
||||
<property name="query" value="//*[self::LITERAL_IF or self::LITERAL_ELSE or self::STATIC_INIT]/SLIST[count(./*)=1]
|
||||
| //*[self::STATIC_INIT or self::LITERAL_TRY or self::LITERAL_IF] //*[self::RCURLY][parent::SLIST[count(./*)=1]]
|
||||
| //SLIST[count(./*)=1][parent::LITERAL_TRY and
|
||||
not(following-sibling::*)]
|
||||
| //SLIST[count(./*)=1][parent::LITERAL_CATCH and
|
||||
not(parent::LITERAL_CATCH/following-sibling::*)]"/>
|
||||
</module>
|
||||
<module name="RegexpSinglelineJava">
|
||||
<property name="format" value="\{[ ]+\}"/>
|
||||
<property name="message" value="Empty blocks should have no spaces. Empty blocks
|
||||
may only be represented as '{}' when not part of a
|
||||
multi-block statement (4.1.3)"/>
|
||||
</module>
|
||||
<module name="OneStatementPerLine"/>
|
||||
<module name="MultipleVariableDeclarations"/>
|
||||
<module name="ArrayTypeStyle"/>
|
||||
<module name="JavadocLeadingAsteriskAlign"/>
|
||||
<module name="JavadocMissingLeadingAsterisk"/>
|
||||
<module name="JavadocContentLocation"/>
|
||||
<module name="MissingSwitchDefault"/>
|
||||
<module name="FallThrough"/>
|
||||
<module name="UpperEll"/>
|
||||
<module name="ModifierOrder"/>
|
||||
<module name="TextBlockGoogleStyleFormatting"/>
|
||||
<module name="EmptyLineSeparator">
|
||||
<property name="tokens" value="PACKAGE_DEF, IMPORT, STATIC_IMPORT, CLASS_DEF, INTERFACE_DEF, ENUM_DEF,
|
||||
STATIC_INIT, INSTANCE_INIT, METHOD_DEF, CTOR_DEF, VARIABLE_DEF, RECORD_DEF,
|
||||
COMPACT_CTOR_DEF"/>
|
||||
<property name="allowNoEmptyLineBetweenFields" value="true"/>
|
||||
<property name="allowMultipleEmptyLines" value="false"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapDot"/>
|
||||
<property name="tokens" value="DOT"/>
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapComma"/>
|
||||
<property name="tokens" value="COMMA"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/259 -->
|
||||
<property name="id" value="SeparatorWrapEllipsis"/>
|
||||
<property name="tokens" value="ELLIPSIS"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/258 -->
|
||||
<property name="id" value="SeparatorWrapArrayDeclarator"/>
|
||||
<property name="tokens" value="ARRAY_DECLARATOR"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapMethodRef"/>
|
||||
<property name="tokens" value="METHOD_REF"/>
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="PackageName">
|
||||
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
|
||||
<message key="name.invalidPattern" value="Package name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="TypeName">
|
||||
<property name="format" value="^[A-Z][a-zA-Z0-9]*(?:[0-9](?:_[0-9]+)*)?$"/>
|
||||
<property name="tokens" value="CLASS_DEF"/>
|
||||
<message key="name.invalidPattern" value="Type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="TypeName">
|
||||
<property name="tokens" value="INTERFACE_DEF, ENUM_DEF,
|
||||
ANNOTATION_DEF, RECORD_DEF"/>
|
||||
<message key="name.invalidPattern" value="Type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="GoogleNonConstantFieldName"/>
|
||||
<module name="ParameterName">
|
||||
<property name="format" value="^[a-z]([a-z0-9][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="LambdaParameterName">
|
||||
<property name="format" value="^(_|[a-z]([a-z0-9][a-zA-Z0-9]*)?)$"/>
|
||||
<message key="name.invalidPattern" value="Lambda parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="CatchParameterName">
|
||||
<property name="format" value="^(_|[a-z]([a-z0-9][a-zA-Z0-9]*)?)$"/>
|
||||
<message key="name.invalidPattern" value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="LocalVariableName">
|
||||
<property name="format" value="^(_|[a-z]([a-z0-9][a-zA-Z0-9]*)?)$"/>
|
||||
<message key="name.invalidPattern" value="Local variable name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="PatternVariableName">
|
||||
<property name="format" value="^(_|[a-z]([a-z0-9][a-zA-Z0-9]*)?)$"/>
|
||||
<message key="name.invalidPattern" value="Pattern variable name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="ClassTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
|
||||
<message key="name.invalidPattern" value="Class type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="RecordComponentName">
|
||||
<property name="format" value="^[a-z]([a-z0-9][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Record component name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="RecordTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
|
||||
<message key="name.invalidPattern" value="Record type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="MethodTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
|
||||
<message key="name.invalidPattern" value="Method type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="InterfaceTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][a-zA-Z0-9]*[T]$)"/>
|
||||
<message key="name.invalidPattern" value="Interface type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="NoFinalizer"/>
|
||||
<module name="GenericWhitespace">
|
||||
<message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
|
||||
<message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
|
||||
<message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
|
||||
<message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
|
||||
</module>
|
||||
<module name="Indentation">
|
||||
<property name="basicOffset" value="2"/>
|
||||
<property name="braceAdjustment" value="2"/>
|
||||
<property name="caseIndent" value="2"/>
|
||||
<property name="throwsIndent" value="4"/>
|
||||
<property name="lineWrappingIndentation" value="4"/>
|
||||
<property name="arrayInitIndent" value="2"/>
|
||||
</module>
|
||||
|
||||
<module name="AbbreviationAsWordInName">
|
||||
<property name="ignoreFinal" value="false"/>
|
||||
<property name="allowedAbbreviationLength" value="0"/>
|
||||
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, ANNOTATION_DEF, ANNOTATION_FIELD_DEF,
|
||||
PARAMETER_DEF, VARIABLE_DEF, METHOD_DEF, PATTERN_VARIABLE_DEF, RECORD_DEF,
|
||||
RECORD_COMPONENT_DEF"/>
|
||||
</module>
|
||||
<module name="NoWhitespaceBeforeCaseDefaultColon"/>
|
||||
<module name="OverloadMethodsDeclarationOrder"/>
|
||||
<module name="ConstructorsDeclarationGrouping"/>
|
||||
<module name="VariableDeclarationUsageDistance"/>
|
||||
<module name="CustomImportOrder">
|
||||
<property name="sortImportsInGroupAlphabetically" value="true"/>
|
||||
<property name="separateLineBetweenGroups" value="true"/>
|
||||
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
|
||||
<property name="tokens" value="IMPORT, STATIC_IMPORT, PACKAGE_DEF"/>
|
||||
</module>
|
||||
<module name="MethodParamPad">
|
||||
<property name="tokens" value="CTOR_DEF, LITERAL_NEW, METHOD_CALL, METHOD_DEF, CTOR_CALL,
|
||||
SUPER_CTOR_CALL, ENUM_CONSTANT_DEF, RECORD_DEF, RECORD_PATTERN_DEF"/>
|
||||
</module>
|
||||
<module name="NoWhitespaceBefore">
|
||||
<property name="tokens" value="COMMA, SEMI, POST_INC, POST_DEC, DOT,
|
||||
LABELED_STAT, METHOD_REF, ELLIPSIS"/>
|
||||
<property name="allowLineBreaks" value="true"/>
|
||||
</module>
|
||||
<module name="SuppressionXpathSingleFilter">
|
||||
<property name="checks" value="NoWhitespaceBefore"/>
|
||||
<property name="query" value="//ELLIPSIS[preceding-sibling::TYPE/ANNOTATIONS[ANNOTATION[LPAREN]
|
||||
or not(following-sibling::*)]]"/>
|
||||
</module>
|
||||
<module name="ParenPad">
|
||||
<property name="tokens" value="ANNOTATION, ANNOTATION_FIELD_DEF, CTOR_CALL, CTOR_DEF, DOT, ENUM_CONSTANT_DEF,
|
||||
EXPR, LITERAL_CATCH, LITERAL_DO, LITERAL_FOR, LITERAL_IF, LITERAL_NEW,
|
||||
LITERAL_SWITCH, LITERAL_SYNCHRONIZED, LITERAL_WHILE, METHOD_CALL,
|
||||
METHOD_DEF, QUESTION, RESOURCE_SPECIFICATION, SUPER_CTOR_CALL, LAMBDA,
|
||||
RECORD_DEF, RECORD_PATTERN_DEF"/>
|
||||
</module>
|
||||
<module name="OperatorWrap">
|
||||
<property name="option" value="NL"/>
|
||||
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR,
|
||||
LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF,
|
||||
TYPE_EXTENSION_AND "/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationTypeAndPackage"/>
|
||||
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, RECORD_DEF, PACKAGE_DEF"/>
|
||||
<property name="allowSamelineSingleParameterlessAnnotation" value="false"/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationMethodsAndCtors"/>
|
||||
<property name="tokens" value="METHOD_DEF, CTOR_DEF, COMPACT_CTOR_DEF"/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationVariables"/>
|
||||
<property name="tokens" value="VARIABLE_DEF"/>
|
||||
<property name="allowSamelineMultipleAnnotations" value="true"/>
|
||||
</module>
|
||||
<module name="MissingOverrideOnRecordAccessor"/>
|
||||
<module name="NonEmptyAtclauseDescription"/>
|
||||
<module name="InvalidJavadocPosition"/>
|
||||
<module name="JavadocTagContinuationIndentation"/>
|
||||
<module name="SummaryJavadoc">
|
||||
<property name="forbiddenSummaryFragments" value="^@return the *|^This method returns |^A [{]@code [a-zA-Z0-9]+[}]( is a )|^[a-z]"/>
|
||||
</module>
|
||||
<module name="JavadocParagraph">
|
||||
<property name="allowNewlineParagraph" value="true"/>
|
||||
</module>
|
||||
<module name="RequireEmptyLineBeforeBlockTagGroup"/>
|
||||
<module name="AtclauseOrder">
|
||||
<property name="tagOrder" value="@param, @return, @throws, @deprecated"/>
|
||||
<property name="target" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF,
|
||||
VARIABLE_DEF, RECORD_DEF, COMPACT_CTOR_DEF"/>
|
||||
</module>
|
||||
<module name="JavadocMethod">
|
||||
<property name="accessModifiers" value="public"/>
|
||||
<property name="allowMissingParamTags" value="true"/>
|
||||
<property name="allowMissingReturnTag" value="true"/>
|
||||
<property name="allowedAnnotations" value="Override, Test"/>
|
||||
<property name="tokens" value="METHOD_DEF, CTOR_DEF, ANNOTATION_FIELD_DEF, COMPACT_CTOR_DEF"/>
|
||||
</module>
|
||||
<module name="MissingJavadocMethod">
|
||||
<property name="scope" value="protected"/>
|
||||
<property name="allowMissingPropertyJavadoc" value="true"/>
|
||||
<property name="allowedAnnotations" value="Override, Test"/>
|
||||
<property name="tokens" value="METHOD_DEF, CTOR_DEF, ANNOTATION_FIELD_DEF,
|
||||
COMPACT_CTOR_DEF"/>
|
||||
</module>
|
||||
<module name="SuppressionXpathSingleFilter">
|
||||
<property name="checks" value="MissingJavadocMethod"/>
|
||||
<property name="query" value="//*[self::METHOD_DEF or self::CTOR_DEF
|
||||
or self::ANNOTATION_FIELD_DEF or self::COMPACT_CTOR_DEF]
|
||||
[ancestor::*[self::INTERFACE_DEF or self::CLASS_DEF
|
||||
or self::RECORD_DEF or self::ENUM_DEF]
|
||||
[not(./MODIFIERS/LITERAL_PUBLIC)]]"/>
|
||||
</module>
|
||||
<module name="MissingJavadocType">
|
||||
<property name="scope" value="protected"/>
|
||||
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF,
|
||||
RECORD_DEF, ANNOTATION_DEF"/>
|
||||
<property name="excludeScope" value="nothing"/>
|
||||
</module>
|
||||
<module name="MethodName">
|
||||
<property name="format" value="^(?![a-z]$)(?![a-z][A-Z])[a-z][a-z0-9]*(?:[A-Z][a-z0-9]*)*(?:_[0-9]+)*$"/>
|
||||
<message key="name.invalidPattern" value="Method name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="SuppressionXpathSingleFilter">
|
||||
<property name="checks" value="MethodName"/>
|
||||
<property name="query" value="//METHOD_DEF[
|
||||
./MODIFIERS/ANNOTATION//IDENT[contains(@text, 'Test')]
|
||||
]/IDENT"/>
|
||||
<property name="message" value="'[a-z][a-z0-9][a-zA-Z0-9]*(?:_[a-z][a-z0-9][a-zA-Z0-9]*)*'"/>
|
||||
</module>
|
||||
<module name="SingleLineJavadoc"/>
|
||||
<module name="TodoComment">
|
||||
<property name="format" value="^[ \t]*(?!TODO:)(?i:TODO)\b:?"/>
|
||||
<message key="todo.match" value="''TODO:'' must be written in all caps and followed by a colon."/>
|
||||
</module>
|
||||
<module name="EmptyCatchBlock">
|
||||
<property name="commentFormat" value="\w+"/>
|
||||
</module>
|
||||
<module name="CommentsIndentation">
|
||||
<property name="tokens" value="SINGLE_LINE_COMMENT, BLOCK_COMMENT_BEGIN"/>
|
||||
</module>
|
||||
<!-- https://checkstyle.org/filters/suppressionxpathfilter.html -->
|
||||
<module name="SuppressionXpathFilter">
|
||||
<property name="file" value="${org.checkstyle.google.suppressionxpathfilter.config}" default="checkstyle-xpath-suppressions.xml" />
|
||||
<property name="optional" value="true"/>
|
||||
</module>
|
||||
<module name="SuppressWarningsHolder" />
|
||||
<module name="SuppressionCommentFilter">
|
||||
<property name="offCommentFormat" value="CHECKSTYLE.OFF\: ([\w\|]+)" />
|
||||
<property name="onCommentFormat" value="CHECKSTYLE.ON\: ([\w\|]+)" />
|
||||
<property name="checkFormat" value="$1" />
|
||||
</module>
|
||||
<module name="SuppressWithNearbyCommentFilter">
|
||||
<property name="commentFormat" value="CHECKSTYLE.SUPPRESS\: ([\w\|]+)"/>
|
||||
<!-- $1 refers to the first match group in the regex defined in commentFormat -->
|
||||
<property name="checkFormat" value="$1"/>
|
||||
<!-- The check is suppressed in the next line of code after the comment -->
|
||||
<property name="influenceFormat" value="1"/>
|
||||
</module>
|
||||
</module>
|
||||
</module>
|
||||
47
docker-compose.yml
Normal file
47
docker-compose.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
services:
|
||||
api-v1:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
API_VERSION: ${API_V1_VERSION:-v1}
|
||||
APP_PORT: ${API_V1_PORT:-8080}
|
||||
image: arcadia-api:${API_V1_VERSION:-v1}
|
||||
container_name: arcadia-api-${API_V1_VERSION:-v1}
|
||||
ports:
|
||||
- "${API_V1_PORT:-8080}:${API_V1_PORT:-8080}"
|
||||
environment:
|
||||
SERVER_PORT: ${API_V1_PORT:-8080}
|
||||
SERVER_SERVLET_CONTEXT_PATH: /api/${API_V1_VERSION:-v1}
|
||||
LOGGING_LEVEL_ROOT: ${API_V1_LOG_LEVEL_ROOT:-INFO}
|
||||
LOGGING_LEVEL_COM_LDC_ARCADIAGENESIS_BACKENDAPI: ${API_V1_LOG_LEVEL_APP:-INFO}
|
||||
SPRINGDOC_SWAGGER_UI_ENABLED: ${API_V1_SWAGGER_ENABLED:-true}
|
||||
SPRINGDOC_API_DOCS_ENABLED: ${API_V1_API_DOCS_ENABLED:-true}
|
||||
SPRINGDOC_API_DOCS_PATH: ${API_V1_API_DOCS_PATH:-/api-docs}
|
||||
SPRINGDOC_SWAGGER_UI_PATH: ${API_V1_SWAGGER_UI_PATH:-/swagger-ui.html}
|
||||
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: ${API_V1_MANAGEMENT_EXPOSURE:-*}
|
||||
MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS: ${API_V1_MANAGEMENT_HEALTH_DETAILS:-always}
|
||||
|
||||
api-v2:
|
||||
profiles: [ "v2" ]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
API_VERSION: ${API_V2_VERSION:-v2}
|
||||
APP_PORT: ${API_V2_PORT:-8081}
|
||||
image: arcadia-api:${API_V2_VERSION:-v2}
|
||||
container_name: arcadia-api-${API_V2_VERSION:-v2}
|
||||
ports:
|
||||
- "${API_V2_PORT:-8081}:${API_V2_PORT:-8081}"
|
||||
environment:
|
||||
SERVER_PORT: ${API_V2_PORT:-8081}
|
||||
SERVER_SERVLET_CONTEXT_PATH: /api/${API_V2_VERSION:-v2}
|
||||
LOGGING_LEVEL_ROOT: ${API_V2_LOG_LEVEL_ROOT:-INFO}
|
||||
LOGGING_LEVEL_COM_LDC_ARCADIAGENESIS_BACKENDAPI: ${API_V2_LOG_LEVEL_APP:-INFO}
|
||||
SPRINGDOC_SWAGGER_UI_ENABLED: ${API_V2_SWAGGER_ENABLED:-true}
|
||||
SPRINGDOC_API_DOCS_ENABLED: ${API_V2_API_DOCS_ENABLED:-true}
|
||||
SPRINGDOC_API_DOCS_PATH: ${API_V2_API_DOCS_PATH:-/api-docs}
|
||||
SPRINGDOC_SWAGGER_UI_PATH: ${API_V2_SWAGGER_UI_PATH:-/swagger-ui.html}
|
||||
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: ${API_V2_MANAGEMENT_EXPOSURE:-*}
|
||||
MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS: ${API_V2_MANAGEMENT_HEALTH_DETAILS:-always}
|
||||
363
pom.xml
363
pom.xml
|
|
@ -1,77 +1,296 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.0.5</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>com.ldc.arcadiagenesis</groupId>
|
||||
<artifactId>backendapi</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name/>
|
||||
<description/>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>3.0.2</version>
|
||||
</dependency>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.ldc.arcadiagenesis</groupId>
|
||||
<artifactId>backendapi</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<packaging>pom</packaging>
|
||||
<name>Arcadia Genesis Backend API</name>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license>
|
||||
<name>MIT License</name>
|
||||
<url>https://opensource.org/licenses/MIT</url>
|
||||
<distribution>repo</distribution>
|
||||
</license>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer>
|
||||
<id>jeremy</id>
|
||||
<name>Jeremy Munsch</name>
|
||||
<email>jeremy.munsch@ldc.fr</email>
|
||||
</developer>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<modules>
|
||||
<module>src/common</module>
|
||||
<module>src/domain</module>
|
||||
<module>src/infrastructure</module>
|
||||
<module>src/application</module>
|
||||
<module>src/api/v1</module>
|
||||
</modules>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<jspecify.version>1.0.0</jspecify.version>
|
||||
<log4j.version>2.25.3</log4j.version>
|
||||
<slf4j.version>2.0.17</slf4j.version>
|
||||
<error-prone.version>2.36.0</error-prone.version>
|
||||
<nullaway.version>0.12.6</nullaway.version>
|
||||
<maven-compiler-plugin.version>3.14.1</maven-compiler-plugin.version>
|
||||
<maven-checkstyle-plugin.version>3.6.0</maven-checkstyle-plugin.version>
|
||||
<checkstyle.version>13.4.0</checkstyle.version>
|
||||
<jacoco.version>0.8.13</jacoco.version>
|
||||
<jacoco.minimum.coverage>0.80</jacoco.minimum.coverage>
|
||||
<spotless.version>2.44.4</spotless.version>
|
||||
<google-java-format.version>1.35.0</google-java-format.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!-- Internal modules -->
|
||||
<dependency>
|
||||
<groupId>com.ldc.arcadiagenesis</groupId>
|
||||
<artifactId>backendapi-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ldc.arcadiagenesis</groupId>
|
||||
<artifactId>backendapi-domain</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ldc.arcadiagenesis</groupId>
|
||||
<artifactId>backendapi-infrastructure</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ldc.arcadiagenesis</groupId>
|
||||
<artifactId>backendapi-application-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- External -->
|
||||
<dependency>
|
||||
<groupId>org.jspecify</groupId>
|
||||
<artifactId>jspecify</artifactId>
|
||||
<version>${jspecify.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
<artifactId>log4j-core</artifactId>
|
||||
<version>${log4j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>${slf4j.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<pluginManagement>
|
||||
<plugins>
|
||||
|
||||
<!-- Error Prone + NullAway -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>${maven-compiler-plugin.version}</version>
|
||||
<configuration>
|
||||
<source>${java.version}</source>
|
||||
<target>${java.version}</target>
|
||||
<encoding>UTF-8</encoding>
|
||||
<compilerArgs>
|
||||
<arg>-XDcompilePolicy=simple</arg>
|
||||
<arg>-XDshould-stop.ifError=FLOW</arg>
|
||||
<arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.ldc.arcadiagenesis.backendapi</arg>
|
||||
</compilerArgs>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>com.google.errorprone</groupId>
|
||||
<artifactId>error_prone_core</artifactId>
|
||||
<version>${error-prone.version}</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>com.uber.nullaway</groupId>
|
||||
<artifactId>nullaway</artifactId>
|
||||
<version>${nullaway.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<!-- Checkstyle (Google style) -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>${maven-checkstyle-plugin.version}</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>${checkstyle.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
<configLocation>${maven.multiModuleProjectDirectory}/checkstyle/checkstyle.xml</configLocation>
|
||||
<!-- <propertyExpansion>org.checkstyle.google.suppressionfilter.config=${maven.multiModuleProjectDirectory}/checkstyle/checkstyle-suppressions.xml</propertyExpansion> -->
|
||||
<consoleOutput>true</consoleOutput>
|
||||
<failsOnError>true</failsOnError>
|
||||
<includeTestSourceDirectory>true</includeTestSourceDirectory>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>checkstyle-verify</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- JaCoCo -->
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>jacoco-prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>jacoco-report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<formats>
|
||||
<format>HTML</format>
|
||||
<format>XML</format>
|
||||
</formats>
|
||||
<outputDirectory>${project.reporting.outputDirectory}/jacoco</outputDirectory>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>jacoco-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>${jacoco.minimum.coverage}</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- Spotless -->
|
||||
<plugin>
|
||||
<groupId>com.diffplug.spotless</groupId>
|
||||
<artifactId>spotless-maven-plugin</artifactId>
|
||||
<version>${spotless.version}</version>
|
||||
<configuration>
|
||||
<java>
|
||||
<googleJavaFormat>
|
||||
<version>${google-java-format.version}</version>
|
||||
<style>GOOGLE</style>
|
||||
</googleJavaFormat>
|
||||
<removeUnusedImports/>
|
||||
<trimTrailingWhitespace/>
|
||||
<endWithNewline/>
|
||||
</java>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>spotless-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- m2e: ignore spotless during IDE incremental builds -->
|
||||
<plugin>
|
||||
<groupId>org.eclipse.m2e</groupId>
|
||||
<artifactId>lifecycle-mapping</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<configuration>
|
||||
<lifecycleMappingMetadata>
|
||||
<pluginExecutions>
|
||||
<pluginExecution>
|
||||
<pluginExecutionFilter>
|
||||
<groupId>com.diffplug.spotless</groupId>
|
||||
<artifactId>spotless-maven-plugin</artifactId>
|
||||
<versionRange>[0,)</versionRange>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</pluginExecutionFilter>
|
||||
<action>
|
||||
<ignore/>
|
||||
</action>
|
||||
</pluginExecution>
|
||||
</pluginExecutions>
|
||||
</lifecycleMappingMetadata>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
|
||||
<!-- Plugins applied to all modules -->
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<compilerArgs>
|
||||
<arg>-parameters</arg>
|
||||
</compilerArgs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.diffplug.spotless</groupId>
|
||||
<artifactId>spotless-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
|
|
|
|||
BIN
resources/Génésis - Documentation client API.docx.pdf
Normal file
BIN
resources/Génésis - Documentation client API.docx.pdf
Normal file
Binary file not shown.
|
|
@ -1,11 +1,15 @@
|
|||
info:
|
||||
name: Root
|
||||
name: Get Employee
|
||||
type: http
|
||||
seq: 1
|
||||
seq: 7
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{HOST}}:{{PORT}}/"
|
||||
url: "{{ENDPOINT}}/employee/:employeeId"
|
||||
params:
|
||||
- name: employeeId
|
||||
value: "1"
|
||||
type: path
|
||||
auth: inherit
|
||||
|
||||
settings:
|
||||
46
resources/bruno/ApiV1/Employee/Paginate Combined Filters.yml
Normal file
46
resources/bruno/ApiV1/Employee/Paginate Combined Filters.yml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
info:
|
||||
name: Paginate Combined Filters
|
||||
type: http
|
||||
seq: 1
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{ENDPOINT}}/employee?page=2&perPage=3&orderBy=startDate&direction=desc&locale=fr&value=1&filters[0].field=matricule&filters[0].operator=like&filters[0].value=MATRICULE_&society=1"
|
||||
params:
|
||||
- name: page
|
||||
value: "2"
|
||||
type: query
|
||||
- name: perPage
|
||||
value: "3"
|
||||
type: query
|
||||
- name: orderBy
|
||||
value: startDate
|
||||
type: query
|
||||
- name: direction
|
||||
value: desc
|
||||
type: query
|
||||
- name: locale
|
||||
value: fr
|
||||
type: query
|
||||
- name: value
|
||||
value: "1"
|
||||
type: query
|
||||
- name: filters[0].field
|
||||
value: matricule
|
||||
type: query
|
||||
- name: filters[0].operator
|
||||
value: like
|
||||
type: query
|
||||
- name: filters[0].value
|
||||
value: MATRICULE_
|
||||
type: query
|
||||
- name: society
|
||||
value: "1"
|
||||
type: query
|
||||
auth: inherit
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
24
resources/bruno/ApiV1/Employee/Paginate Employees.yml
Normal file
24
resources/bruno/ApiV1/Employee/Paginate Employees.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
info:
|
||||
name: Paginate Employees
|
||||
type: http
|
||||
seq: 6
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{ENDPOINT}}/employee"
|
||||
params:
|
||||
- name: page
|
||||
value: "2"
|
||||
type: query
|
||||
disabled: true
|
||||
- name: perPage
|
||||
value: "3"
|
||||
type: query
|
||||
disabled: true
|
||||
auth: inherit
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
info:
|
||||
name: Paginate and Filter BETWEEN Two Columns
|
||||
type: http
|
||||
seq: 8
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{ENDPOINT}}/employee?page=2&perPage=10&filters[0].field=startDate&filters[0].operator=between&filters[0].value=2000-01-01T00:00:00Z|2100-01-01T00:00:00Z&filters[1].field=createdAt&filters[1].operator=between&filters[1].value=2000-01-01T00:00:00Z|2100-01-01T00:00:00Z"
|
||||
params:
|
||||
- name: page
|
||||
value: "2"
|
||||
type: query
|
||||
- name: perPage
|
||||
value: "10"
|
||||
type: query
|
||||
- name: filters[0].field
|
||||
value: startDate
|
||||
type: query
|
||||
- name: filters[0].operator
|
||||
value: between
|
||||
type: query
|
||||
- name: filters[0].value
|
||||
value: 2000-01-01T00:00:00Z|2100-01-01T00:00:00Z
|
||||
type: query
|
||||
- name: filters[1].field
|
||||
value: createdAt
|
||||
type: query
|
||||
- name: filters[1].operator
|
||||
value: between
|
||||
type: query
|
||||
- name: filters[1].value
|
||||
value: 2000-01-01T00:00:00Z|2100-01-01T00:00:00Z
|
||||
type: query
|
||||
auth: inherit
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
info:
|
||||
name: Paginate and Filter Employees
|
||||
type: http
|
||||
seq: 2
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{ENDPOINT}}/employee?page=1&perPage=3&filters[0].field=matricule&filters[0].operator=like&filters[0].value=ULE_1"
|
||||
params:
|
||||
- name: page
|
||||
value: "1"
|
||||
type: query
|
||||
- name: perPage
|
||||
value: "3"
|
||||
type: query
|
||||
- name: filters[0].field
|
||||
value: matricule
|
||||
type: query
|
||||
- name: filters[0].operator
|
||||
value: like
|
||||
type: query
|
||||
- name: filters[0].value
|
||||
value: ULE_1
|
||||
type: query
|
||||
auth: inherit
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
31
resources/bruno/ApiV1/Employee/Paginate and Filter IN.yml
Normal file
31
resources/bruno/ApiV1/Employee/Paginate and Filter IN.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
info:
|
||||
name: Paginate and Filter IN
|
||||
type: http
|
||||
seq: 9
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{ENDPOINT}}/employee?page=1&perPage=10&filters[0].field=siteCode&filters[0].operator=in&filters[0].value=SITE_3|SITE_2"
|
||||
params:
|
||||
- name: page
|
||||
value: "1"
|
||||
type: query
|
||||
- name: perPage
|
||||
value: "10"
|
||||
type: query
|
||||
- name: filters[0].field
|
||||
value: siteCode
|
||||
type: query
|
||||
- name: filters[0].operator
|
||||
value: in
|
||||
type: query
|
||||
- name: filters[0].value
|
||||
value: SITE_3|SITE_2
|
||||
type: query
|
||||
auth: inherit
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
info:
|
||||
name: Paginate and Filter by Locale
|
||||
type: http
|
||||
seq: 3
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{ENDPOINT}}/employee?page=2&perPage=3&locale=en"
|
||||
params:
|
||||
- name: page
|
||||
value: "2"
|
||||
type: query
|
||||
- name: perPage
|
||||
value: "3"
|
||||
type: query
|
||||
- name: locale
|
||||
value: en
|
||||
type: query
|
||||
auth: inherit
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
info:
|
||||
name: Paginate and Filter by Society
|
||||
type: http
|
||||
seq: 4
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{ENDPOINT}}/employee?page=2&perPage=3&society=2"
|
||||
params:
|
||||
- name: page
|
||||
value: "2"
|
||||
type: query
|
||||
- name: perPage
|
||||
value: "3"
|
||||
type: query
|
||||
- name: society
|
||||
value: "2"
|
||||
type: query
|
||||
auth: inherit
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
info:
|
||||
name: Paginate and Sort Employees
|
||||
type: http
|
||||
seq: 5
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{ENDPOINT}}/employee/?from=1&to=5&orderBy=siteCode&direction=desc"
|
||||
params:
|
||||
- name: from
|
||||
value: "1"
|
||||
type: query
|
||||
- name: to
|
||||
value: "5"
|
||||
type: query
|
||||
- name: orderBy
|
||||
value: siteCode
|
||||
type: query
|
||||
- name: direction
|
||||
value: desc
|
||||
type: query
|
||||
auth: inherit
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
7
resources/bruno/ApiV1/Employee/folder.yml
Normal file
7
resources/bruno/ApiV1/Employee/folder.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
info:
|
||||
name: Employee
|
||||
type: folder
|
||||
seq: 1
|
||||
|
||||
request:
|
||||
auth: inherit
|
||||
7
resources/bruno/ApiV1/folder.yml
Normal file
7
resources/bruno/ApiV1/folder.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
info:
|
||||
name: ApiV1
|
||||
type: folder
|
||||
seq: 1
|
||||
|
||||
request:
|
||||
auth: inherit
|
||||
|
|
@ -4,3 +4,7 @@ variables:
|
|||
value: 127.0.0.1
|
||||
- name: PORT
|
||||
value: "8080"
|
||||
- name: VERSION
|
||||
value: v1
|
||||
- name: ENDPOINT
|
||||
value: "http://{{HOST}}:{{PORT}}/api/{{VERSION}}"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,21 @@ config:
|
|||
username: ""
|
||||
password: ""
|
||||
bypassProxy: ""
|
||||
|
||||
request:
|
||||
headers:
|
||||
- name: Content-Type
|
||||
value: application/json
|
||||
- name: Accept
|
||||
value: application/json
|
||||
- name: X-API-Key
|
||||
value: LdcDigitalService
|
||||
- name: X-API-Signature
|
||||
value: DUMMYSIGN
|
||||
- name: X-Nonce
|
||||
value: DUMMYSIGN
|
||||
- name: X-Timestamp
|
||||
value: "{{$isoTimestamp}}"
|
||||
bundled: false
|
||||
extensions:
|
||||
bruno:
|
||||
|
|
|
|||
123
src/api/v1/pom.xml
Normal file
123
src/api/v1/pom.xml
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ldc.arcadiagenesis</groupId>
|
||||
<artifactId>backendapi</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<relativePath>../../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>backendapi-api-v1</artifactId>
|
||||
<name>Backend API - API v1</name>
|
||||
|
||||
<properties>
|
||||
<spring-boot.version>4.0.5</spring-boot.version>
|
||||
<openapi.version>3.0.2</openapi.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
|
||||
<version>${openapi.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>${openapi.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<!-- Internal modules -->
|
||||
<dependency>
|
||||
<groupId>com.ldc.arcadiagenesis</groupId>
|
||||
<artifactId>backendapi-common</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ldc.arcadiagenesis</groupId>
|
||||
<artifactId>backendapi-domain</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ldc.arcadiagenesis</groupId>
|
||||
<artifactId>backendapi-infrastructure</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ldc.arcadiagenesis</groupId>
|
||||
<artifactId>backendapi-application-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Mandatory -->
|
||||
<dependency>
|
||||
<groupId>org.jspecify</groupId>
|
||||
<artifactId>jspecify</artifactId>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Boot -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-tx</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- OpenAPI / Swagger -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1;
|
||||
|
||||
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||
import io.swagger.v3.oas.annotations.info.Info;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
@OpenAPIDefinition(
|
||||
info =
|
||||
@Info(
|
||||
title = "Apply Default Global SecurityScheme in springdoc-openapi",
|
||||
version = "1.0.0"),
|
||||
security = {
|
||||
@SecurityRequirement(name = "api_key"),
|
||||
@SecurityRequirement(name = "api_signature"),
|
||||
@SecurityRequirement(name = "timestamp")
|
||||
})
|
||||
@SpringBootApplication(scanBasePackages = "com.ldc.arcadiagenesis.backendapi")
|
||||
@ConfigurationPropertiesScan
|
||||
public class BackendapiApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(BackendapiApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.seed.EmployeeSeeder;
|
||||
import java.util.Arrays;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationStartedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class StartUpSeedRunner {
|
||||
private static Logger logger = LoggerFactory.getLogger(StartUpSeedRunner.class);
|
||||
private static final String SEED_ON_PROFILE = "seed";
|
||||
|
||||
private EmployeeSeeder employeeSeeder;
|
||||
|
||||
public StartUpSeedRunner(EmployeeSeeder employeeSeeder) {
|
||||
this.employeeSeeder = employeeSeeder;
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void onStateChange(ApplicationStartedEvent event) {
|
||||
final var appCtx = event.getApplicationContext();
|
||||
final var appEnv = appCtx.getEnvironment();
|
||||
|
||||
if (Arrays.asList(appEnv.getActiveProfiles()).contains(SEED_ON_PROFILE)) {
|
||||
runSeeders();
|
||||
}
|
||||
}
|
||||
|
||||
private void runSeeders() {
|
||||
logger.info("Running seeders.");
|
||||
|
||||
employeeSeeder.seed();
|
||||
|
||||
logger.info("Seeders Done.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.components;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.config.SignatureProperties;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.config.SpringProfiles;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions.auth.AuthenticationFailureException;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.services.RequestSignatureService;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.services.apisecurity.ApiKey;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.services.apisecurity.ApiKeyStore;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.services.apisecurity.ApiSecret;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.lib.utils.UuidV7;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.web.servlet.HandlerExceptionResolver;
|
||||
|
||||
@Component
|
||||
public class ApiSignatureFilter extends OncePerRequestFilter {
|
||||
private static final String HEADER_API_KEY = "X-API-Key";
|
||||
private static final String HEADER_API_SIGNATURE = "X-API-Signature";
|
||||
private static final String HEADER_TIMESTAMP = "X-Timestamp";
|
||||
private static final String HEADER_NONCE = "X-Nonce";
|
||||
|
||||
private static final List<String> byPassProfiles = List.of("development", "seed");
|
||||
|
||||
private final RequestSignatureService signatureService;
|
||||
private final ApiKeyStore apiKeyStore;
|
||||
private final SignatureProperties signatureProperties;
|
||||
private final SpringProfiles springProfiles;
|
||||
private final HandlerExceptionResolver exceptionResolver;
|
||||
|
||||
public ApiSignatureFilter(
|
||||
RequestSignatureService signatureService,
|
||||
ApiKeyStore apiKeyStore,
|
||||
SignatureProperties signatureProperties,
|
||||
SpringProfiles springProfiles,
|
||||
@Qualifier("handlerExceptionResolver") HandlerExceptionResolver exceptionResolver) {
|
||||
this.signatureService = signatureService;
|
||||
this.apiKeyStore = apiKeyStore;
|
||||
this.signatureProperties = signatureProperties;
|
||||
this.springProfiles = springProfiles;
|
||||
this.exceptionResolver = exceptionResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse servletResponse, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
try {
|
||||
final var apiKey = request.getHeader(HEADER_API_KEY);
|
||||
final var signature = request.getHeader(HEADER_API_SIGNATURE);
|
||||
final var timestamp = request.getHeader(HEADER_TIMESTAMP);
|
||||
final var nonce = request.getHeader(HEADER_NONCE);
|
||||
|
||||
if (apiKey == null || signature == null || timestamp == null || nonce == null) {
|
||||
logger.debug(
|
||||
"Request not signed apiKey:'%s' signature:'%s' timestamp:'%s' nonce:'%s'"
|
||||
.formatted(apiKey, signature, timestamp, nonce));
|
||||
filterChain.doFilter(request, servletResponse);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signatureProperties.enabled() && byPassProfiles.contains(springProfiles.active())) {
|
||||
logger.warn("Skipping request signature verification");
|
||||
|
||||
// * Force authentication context
|
||||
setAuthenticationContext(request, toApiKey(apiKey));
|
||||
|
||||
filterChain.doFilter(request, servletResponse);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authenticateBySignature(request)) {
|
||||
throw new AuthenticationFailureException("Invalid request signature.");
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, servletResponse);
|
||||
} catch (AuthenticationFailureException e) {
|
||||
exceptionResolver.resolveException(request, servletResponse, null, e);
|
||||
} catch (Exception e) {
|
||||
logger.error("Error during filter processing");
|
||||
exceptionResolver.resolveException(request, servletResponse, null, e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean authenticateBySignature(HttpServletRequest request) {
|
||||
|
||||
final var apiKey = toApiKey(request.getHeader(HEADER_API_KEY));
|
||||
final var signature = request.getHeader(HEADER_API_SIGNATURE);
|
||||
final var timestamp = toInstant(request.getHeader(HEADER_TIMESTAMP));
|
||||
final var nonce = toUuidV7(request.getHeader(HEADER_NONCE));
|
||||
|
||||
final ApiSecret secret =
|
||||
Optional.of(apiKey)
|
||||
.flatMap(apiKeyStore::findSecretByApiKey)
|
||||
.orElseThrow(() -> new AuthenticationFailureException("Invalid API key."));
|
||||
|
||||
final var body = readBody(request);
|
||||
final var queryString = request.getQueryString();
|
||||
|
||||
final var sigRequest =
|
||||
new RequestSignatureService.Request(
|
||||
request.getMethod(),
|
||||
request.getContentType(),
|
||||
request.getScheme(),
|
||||
request.getServerName(),
|
||||
request.getRequestURI(),
|
||||
queryString != null ? queryString : "",
|
||||
body);
|
||||
|
||||
if (!signatureService.isSignatureValid(sigRequest, signature, timestamp, nonce, secret)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setAuthenticationContext(request, apiKey);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void setAuthenticationContext(HttpServletRequest request, ApiKey apiKey) {
|
||||
final var userDetails = new ApiUser(apiKey);
|
||||
final SecurityContext securityContext = getNewSecurityContext(request, userDetails, apiKey);
|
||||
SecurityContextHolder.setContext(securityContext);
|
||||
}
|
||||
|
||||
private SecurityContext getNewSecurityContext(
|
||||
HttpServletRequest request, UserDetails userDetails, ApiKey apiKey) {
|
||||
final var context = SecurityContextHolder.createEmptyContext();
|
||||
final var authToken =
|
||||
new UsernamePasswordAuthenticationToken(userDetails, apiKey, userDetails.getAuthorities());
|
||||
|
||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
|
||||
context.setAuthentication(authToken);
|
||||
return context;
|
||||
}
|
||||
|
||||
private final ApiKey toApiKey(@Nullable String apiKeyValue) {
|
||||
try {
|
||||
if (apiKeyValue == null || apiKeyValue.isBlank()) {
|
||||
throw new AuthenticationFailureException("API key cannot be empty");
|
||||
}
|
||||
|
||||
return new ApiKey(apiKeyValue);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new AuthenticationFailureException("Invalid API key format", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readBody(HttpServletRequest request) {
|
||||
try {
|
||||
return new String(request.getInputStream().readAllBytes());
|
||||
} catch (IOException _) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static Instant toInstant(@Nullable String timestampValue) {
|
||||
if (timestampValue == null || timestampValue.isBlank()) {
|
||||
throw new AuthenticationFailureException("Timestamp cannot be empty");
|
||||
}
|
||||
try {
|
||||
return Instant.parse(timestampValue);
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new AuthenticationFailureException(
|
||||
"Invalid timestamp format: must be ISO 8601 with timezone", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static UUID toUuidV7(@Nullable String nonceValue) {
|
||||
if (nonceValue == null || nonceValue.isBlank()) {
|
||||
throw new AuthenticationFailureException("Nonce cannot be empty");
|
||||
}
|
||||
try {
|
||||
final var uuid = UUID.fromString(nonceValue);
|
||||
UuidV7.extractInstant(uuid);
|
||||
return uuid;
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new AuthenticationFailureException("Invalid nonce: must be a valid UUID v7", e);
|
||||
}
|
||||
}
|
||||
|
||||
public final class ApiUser implements UserDetails {
|
||||
public static final String ROLE = "USER";
|
||||
private final transient ApiKey apiKey;
|
||||
|
||||
public ApiUser(ApiKey apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return List.of(new Authority(ROLE));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return apiKey.value();
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return ROLE + "->" + apiKey.value();
|
||||
}
|
||||
|
||||
public record Authority(String role) implements GrantedAuthority {
|
||||
|
||||
@Override
|
||||
public String getAuthority() {
|
||||
return role;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
@org.jspecify.annotations.NullMarked
|
||||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.components;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.config;
|
||||
|
||||
import java.util.Locale;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.LocaleResolver;
|
||||
import org.springframework.web.servlet.i18n.FixedLocaleResolver;
|
||||
|
||||
@Configuration
|
||||
public class LocaleConfig {
|
||||
|
||||
@Bean
|
||||
LocaleResolver localeResolver() {
|
||||
return new FixedLocaleResolver(Locale.ENGLISH);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Contact;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class OpenApiConfig {
|
||||
|
||||
public static final String API_KEY_HEADER_NAME = "X-API-Key";
|
||||
public static final String API_SIGNATURE_HEADER_NAME = "X-API-Signature";
|
||||
public static final String API_TIMESTAMP_HEADER_NAME = "X-Timestamp";
|
||||
|
||||
@Bean
|
||||
OpenAPI openApi() {
|
||||
return new OpenAPI()
|
||||
.addSecurityItem(
|
||||
new SecurityRequirement()
|
||||
.addList(API_KEY_HEADER_NAME)
|
||||
.addList(API_SIGNATURE_HEADER_NAME)
|
||||
.addList(API_TIMESTAMP_HEADER_NAME))
|
||||
.components(
|
||||
new Components()
|
||||
.addSecuritySchemes(API_KEY_HEADER_NAME, apiKeyHeader(API_KEY_HEADER_NAME))
|
||||
.addSecuritySchemes(
|
||||
API_SIGNATURE_HEADER_NAME, apiKeyHeader(API_SIGNATURE_HEADER_NAME))
|
||||
.addSecuritySchemes(
|
||||
API_TIMESTAMP_HEADER_NAME, apiKeyHeader(API_TIMESTAMP_HEADER_NAME)))
|
||||
.info(
|
||||
new Info()
|
||||
.title("Arcadia Genesis OpenApi")
|
||||
.description("Arcadia Genesis OpenApi using Spring Boot and Spring Security")
|
||||
.version("0.0.1")
|
||||
.contact(
|
||||
new Contact()
|
||||
.name("Munsch Jeremy")
|
||||
.email("github@jeremydev.ovh")
|
||||
.url("https://jeremydev.ovh")));
|
||||
}
|
||||
|
||||
private SecurityScheme apiKeyHeader(String headerName) {
|
||||
return new SecurityScheme()
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.in(SecurityScheme.In.HEADER)
|
||||
.name(headerName);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "api.signature")
|
||||
public record SignatureProperties(boolean enabled) {}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "spring.profiles")
|
||||
public record SpringProfiles(String active) {}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.config;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.components.ApiSignatureFilter;
|
||||
import java.util.List;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@Configuration
|
||||
public class SpringSecurityConfig {
|
||||
|
||||
/** User Argon2 instead of BCrypt. */
|
||||
@Bean
|
||||
PasswordEncoder passwordEncoder() {
|
||||
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(
|
||||
HttpSecurity http, ApiSignatureFilter staticCredentialFilter) {
|
||||
|
||||
final List<String> routesToIgnore =
|
||||
List.of("/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/actuator/**");
|
||||
|
||||
return http.sessionManagement(
|
||||
// No cookie session, just state less API.
|
||||
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
// No CSRF for stateless APIs.
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
// 401 on unauthenticated requests.
|
||||
.exceptionHandling(
|
||||
handling ->
|
||||
handling.authenticationEntryPoint(
|
||||
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||
// All requests must be authenticated unless explicitly ignored.
|
||||
.authorizeHttpRequests(
|
||||
request -> {
|
||||
// Allow non protected AuthRequestToUrls are not protected.
|
||||
request.requestMatchers(routesToIgnore.toArray(String[]::new)).permitAll();
|
||||
|
||||
// Any other routes are.
|
||||
request.anyRequest().fullyAuthenticated();
|
||||
})
|
||||
// Custom filter to process our static user authentication.
|
||||
.addFilterAt(staticCredentialFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.config;
|
||||
|
||||
import java.time.Clock;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class TimeConfig {
|
||||
|
||||
@Bean
|
||||
Clock clock() {
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
@org.jspecify.annotations.NullMarked
|
||||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.config;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.dto;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
public record ApiErrorDetails(String message, ZonedDateTime timestamp) {}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
@org.jspecify.annotations.NullMarked
|
||||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.dto;
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.validation.ValidCriteriaOperator;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import java.util.List;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@Schema(description = "Dynamic filtering parameters")
|
||||
public record CriteriaFilteringDto(
|
||||
@Schema(description = "List of filters. Use query keys like filters[0].field/operator/value")
|
||||
List<@Valid CriteriaDto> filters) {
|
||||
public CriteriaFilteringDto {
|
||||
filters = filters == null ? List.of() : List.copyOf(filters);
|
||||
}
|
||||
|
||||
public record CriteriaDto(
|
||||
@Schema(description = "Field name to filter", example = "createdAt") @NotBlank String field,
|
||||
@Schema(
|
||||
description = "Filter operator",
|
||||
example = "between",
|
||||
allowableValues = {
|
||||
"eq",
|
||||
"neq",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"between",
|
||||
"not_between",
|
||||
"like",
|
||||
"not_like",
|
||||
"in",
|
||||
"not_in"
|
||||
})
|
||||
@NotBlank
|
||||
@ValidCriteriaOperator
|
||||
String operator,
|
||||
@Schema(
|
||||
description =
|
||||
"Filter value. For between/not_between use two pipe values. "
|
||||
+ "For in/not_in use pipe values",
|
||||
example = "2020-01-01T00:00:00Z|2030-01-01T00:00:00Z")
|
||||
@Nullable
|
||||
@jakarta.annotation.Nullable
|
||||
String value) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.validation.ValidLocale;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@Schema(description = "Locale filtering parameter")
|
||||
public record LocaleDto(
|
||||
@Schema(
|
||||
description = "Locale code",
|
||||
example = "fr",
|
||||
allowableValues = {"fr", "en"})
|
||||
@Nullable
|
||||
@jakarta.annotation.Nullable
|
||||
@ValidLocale
|
||||
String locale) {}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.validation.ValidOrdering;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@ValidOrdering
|
||||
@Schema(description = "Ordering parameters")
|
||||
public record OrderingDto(
|
||||
@Schema(description = "Field used for ordering", example = "createdAt")
|
||||
@Nullable String orderBy,
|
||||
@Schema(
|
||||
description = "Ordering direction",
|
||||
example = "asc",
|
||||
allowableValues = {"asc", "desc"})
|
||||
@Nullable String direction) {}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.validation.PaginationRange;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@PaginationRange
|
||||
@Schema(description = "Pagination request parameters")
|
||||
public record PaginationDto(
|
||||
@Schema(description = "Page number (1-based)", example = "1", minimum = "1")
|
||||
@Nullable
|
||||
@jakarta.annotation.Nullable
|
||||
@Positive
|
||||
Long page,
|
||||
@Schema(description = "Page size", example = "10", minimum = "1")
|
||||
@Nullable
|
||||
@jakarta.annotation.Nullable
|
||||
@Positive
|
||||
Long perPage,
|
||||
@Schema(description = "Start index (0-based, inclusive)", example = "0", minimum = "0")
|
||||
@Nullable
|
||||
@jakarta.annotation.Nullable
|
||||
@PositiveOrZero
|
||||
Long from,
|
||||
@Schema(description = "End index (0-based, inclusive)", example = "9", minimum = "1")
|
||||
@Nullable
|
||||
@jakarta.annotation.Nullable
|
||||
@Positive
|
||||
Long to,
|
||||
@Schema(description = "Offset (0-based)", example = "0", minimum = "0")
|
||||
@Nullable
|
||||
@jakarta.annotation.Nullable
|
||||
@PositiveOrZero
|
||||
Long offset,
|
||||
@Schema(description = "Limit", example = "10", minimum = "1")
|
||||
@Nullable
|
||||
@jakarta.annotation.Nullable
|
||||
@Positive
|
||||
Long limit) {}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@Schema(description = "Society filtering parameter")
|
||||
public record SocietyIdDto(
|
||||
@Schema(description = "Society identifier", example = "2", minimum = "1") @Nullable @Positive
|
||||
Integer society) {}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
@org.jspecify.annotations.NullMarked
|
||||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PageOfDto<T>(
|
||||
List<T> data,
|
||||
long totalElements,
|
||||
long totalPages,
|
||||
long page,
|
||||
long pageSize,
|
||||
boolean hasNext,
|
||||
boolean hasPrevious) {}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
@org.jspecify.annotations.NullMarked
|
||||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.response;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.event.listener;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.events.DomainEventListener;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.events.EntityDeleted;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class DeleteEventListener implements DomainEventListener<EntityDeleted<?>> {
|
||||
private static final Logger logger = LoggerFactory.getLogger(DeleteEventListener.class);
|
||||
|
||||
@Override
|
||||
@EventListener
|
||||
public void onEvent(EntityDeleted<?> event) {
|
||||
final var payLoad = event.payload();
|
||||
final var entityType = payLoad.element().getSimpleName();
|
||||
final var entityId = payLoad.id().value();
|
||||
final var user = getCurrentUser();
|
||||
|
||||
logger.info("USER [{}] -> Entity deleted: {} with ID {}", user, entityType, entityId);
|
||||
}
|
||||
|
||||
private String getCurrentUser() {
|
||||
return Optional.ofNullable(SecurityContextHolder.getContext())
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.map(Authentication::getName)
|
||||
.orElse("Unknown");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.event.listener;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.events.DomainEventListener;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.events.EntityPersisted;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.annotation.RequestScope;
|
||||
|
||||
@Component
|
||||
public class PersistedEventListener implements DomainEventListener<EntityPersisted<?>> {
|
||||
private static final Logger logger = LoggerFactory.getLogger(PersistedEventListener.class);
|
||||
|
||||
@Override
|
||||
@EventListener
|
||||
@RequestScope
|
||||
public void onEvent(EntityPersisted<?> event) {
|
||||
final var payLoad = event.payload();
|
||||
final var entityType = payLoad.element().getSimpleName();
|
||||
final var entityId = payLoad.id().value();
|
||||
final var user = getCurrentUser();
|
||||
|
||||
logger.info("USER [{}] -> Entity persisted: {} with ID {}", user, entityType, entityId);
|
||||
}
|
||||
|
||||
private String getCurrentUser() {
|
||||
return Optional.ofNullable(SecurityContextHolder.getContext())
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.map(Authentication::getName)
|
||||
.orElse("Unknown");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.event.listener;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.events.DomainEventListener;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.events.EntityRead;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class ReadEventListener implements DomainEventListener<EntityRead<?>> {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ReadEventListener.class);
|
||||
|
||||
@Override
|
||||
@EventListener
|
||||
public void onEvent(EntityRead<?> event) {
|
||||
final var payLoad = event.payload();
|
||||
final var entityType = payLoad.element().getSimpleName();
|
||||
final var entityId = payLoad.id().value();
|
||||
final var user = getCurrentUser();
|
||||
|
||||
logger.info("USER [{}] -> Entity read: {} with ID {}", user, entityType, entityId);
|
||||
}
|
||||
|
||||
private String getCurrentUser() {
|
||||
return Optional.ofNullable(SecurityContextHolder.getContext())
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.map(Authentication::getName)
|
||||
.orElse("Unknown");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.event.listener;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.events.DomainEventListener;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.events.EntitiesRead;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class ReadMultipleEventListener implements DomainEventListener<EntitiesRead<?>> {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ReadMultipleEventListener.class);
|
||||
|
||||
@Override
|
||||
@EventListener
|
||||
public void onEvent(EntitiesRead<?> event) {
|
||||
final var payLoad = event.payload();
|
||||
final var entityType = payLoad.element().getSimpleName();
|
||||
final var user = getCurrentUser();
|
||||
|
||||
logger.info("USER [{}] ->Entities read: {}", user, entityType);
|
||||
}
|
||||
|
||||
private String getCurrentUser() {
|
||||
return Optional.ofNullable(SecurityContextHolder.getContext())
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.map(Authentication::getName)
|
||||
.orElse("Unknown");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions.auth.AuthenticationFailureException;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import java.net.URI;
|
||||
import java.time.Clock;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.context.MessageSourceResolvable;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.ServletWebRequest;
|
||||
import org.springframework.web.context.request.WebRequest;
|
||||
import org.springframework.web.method.annotation.HandlerMethodValidationException;
|
||||
|
||||
/** RFC 9457 compatible. */
|
||||
@RestControllerAdvice
|
||||
public class AppExceptionHandler {
|
||||
private static final String VALIDATION_ERROR_422_DETAIL = "Some fields could not be validated";
|
||||
private final Clock clock;
|
||||
|
||||
public AppExceptionHandler(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
BadRequestException.class,
|
||||
BadPaginationException.class,
|
||||
BadCriteriaException.class,
|
||||
BadOrderingException.class,
|
||||
BadLocaleException.class,
|
||||
BadSocietyIdException.class
|
||||
})
|
||||
public ResponseEntity<ProblemDetail> handleBadRequest(
|
||||
final RuntimeException ex, final WebRequest request) {
|
||||
return toProblem(HttpStatus.BAD_REQUEST, ex, request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(NotFoundException.class)
|
||||
public ResponseEntity<ProblemDetail> handleException(
|
||||
final NotFoundException ex, final WebRequest request) {
|
||||
return toProblem(HttpStatus.NOT_FOUND, ex, request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(AuthenticationFailureException.class)
|
||||
public ResponseEntity<ProblemDetail> handleException(
|
||||
final AuthenticationFailureException ex, final WebRequest request) {
|
||||
return toProblem(HttpStatus.UNAUTHORIZED, ex, request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ProblemDetail> handleException(
|
||||
final Exception ex, final WebRequest request) {
|
||||
return toProblem(HttpStatus.INTERNAL_SERVER_ERROR, ex, request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ProblemDetail> handleValidationException(
|
||||
final MethodArgumentNotValidException ex, final WebRequest request) {
|
||||
final var errors = new LinkedHashMap<String, String>();
|
||||
|
||||
ex.getBindingResult()
|
||||
.getAllErrors()
|
||||
.forEach(
|
||||
error -> {
|
||||
final var fieldName =
|
||||
error instanceof FieldError fieldError
|
||||
? fieldError.getField()
|
||||
: error.getObjectName();
|
||||
final var message =
|
||||
error.getDefaultMessage() != null
|
||||
? error.getDefaultMessage()
|
||||
: "validation error";
|
||||
errors.put(fieldName, message);
|
||||
});
|
||||
|
||||
return toProblem(
|
||||
HttpStatus.UNPROCESSABLE_CONTENT, VALIDATION_ERROR_422_DETAIL, request, errors);
|
||||
}
|
||||
|
||||
@ExceptionHandler(HandlerMethodValidationException.class)
|
||||
public ResponseEntity<ProblemDetail> handleValidationException(
|
||||
final HandlerMethodValidationException ex, final WebRequest request) {
|
||||
final var errors = new LinkedHashMap<String, String>();
|
||||
|
||||
ex.getParameterValidationResults()
|
||||
.forEach(
|
||||
validationResult -> {
|
||||
final var parameterName = validationResult.getMethodParameter().getParameterName();
|
||||
final var fieldName = parameterName != null ? parameterName : "unknown";
|
||||
final var message =
|
||||
validationResult.getResolvableErrors().stream()
|
||||
.map(MessageSourceResolvable::getDefaultMessage)
|
||||
.filter(defaultMessage -> defaultMessage != null && !defaultMessage.isBlank())
|
||||
.findFirst()
|
||||
.orElse("validation error");
|
||||
errors.put(fieldName, message);
|
||||
});
|
||||
|
||||
return toProblem(
|
||||
HttpStatus.UNPROCESSABLE_CONTENT, VALIDATION_ERROR_422_DETAIL, request, errors);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
public ResponseEntity<ProblemDetail> handleValidationException(
|
||||
final ConstraintViolationException ex, final WebRequest request) {
|
||||
final var errors = new LinkedHashMap<String, String>();
|
||||
|
||||
ex.getConstraintViolations()
|
||||
.forEach(
|
||||
violation -> {
|
||||
final var fieldName = violation.getPropertyPath().toString();
|
||||
final var message = violation.getMessage();
|
||||
errors.put(fieldName, message);
|
||||
});
|
||||
|
||||
return toProblem(
|
||||
HttpStatus.UNPROCESSABLE_CONTENT, VALIDATION_ERROR_422_DETAIL, request, errors);
|
||||
}
|
||||
|
||||
private String getRequestUri(final WebRequest request) {
|
||||
if (request instanceof ServletWebRequest servletWebRequest) {
|
||||
return servletWebRequest.getRequest().getRequestURI();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private ResponseEntity<ProblemDetail> toProblem(
|
||||
HttpStatus status, Throwable throwable, WebRequest request) {
|
||||
final var detail =
|
||||
throwable.getMessage() != null ? throwable.getMessage() : status.getReasonPhrase();
|
||||
return toProblem(status, detail, request, Map.of());
|
||||
}
|
||||
|
||||
private ResponseEntity<ProblemDetail> toProblem(
|
||||
HttpStatus status, String detail, WebRequest request, Map<String, String> errors) {
|
||||
final var problemDetail = ProblemDetail.forStatusAndDetail(status, detail);
|
||||
|
||||
problemDetail.setTitle(status.getReasonPhrase());
|
||||
problemDetail.setType(URI.create("about:blank"));
|
||||
problemDetail.setInstance(URI.create(getRequestUri(request)));
|
||||
problemDetail.setProperty("timestamp", ZonedDateTime.now(clock));
|
||||
if (!errors.isEmpty()) {
|
||||
problemDetail.setProperty("errors", errors);
|
||||
}
|
||||
|
||||
return new ResponseEntity<>(problemDetail, status);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public class BadCriteriaException extends RuntimeException {
|
||||
public BadCriteriaException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public class BadLocaleException extends RuntimeException {
|
||||
public BadLocaleException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public class BadOrderingException extends RuntimeException {
|
||||
public BadOrderingException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public class BadPaginationException extends RuntimeException {
|
||||
public BadPaginationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public class BadRequestException extends RuntimeException {
|
||||
public BadRequestException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public BadRequestException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public class BadSocietyIdException extends RuntimeException {
|
||||
public BadSocietyIdException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public class NotFoundException extends RuntimeException {
|
||||
public NotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public NotFoundException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions.auth;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||
public class AuthenticationFailureException extends RuntimeException {
|
||||
public AuthenticationFailureException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public AuthenticationFailureException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.mappers;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.CriteriaFilteringDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.CriteriaFilteringDto.CriteriaDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions.BadCriteriaException;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.IllegalDomainStateException;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.CriteriaFiltering;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.CriteriaFiltering.Criteria;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.CriteriaFiltering.CriteriaOperator;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.CriteriaFiltering.Field;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.CriteriaFiltering.FilterValue;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class CriteriasMapper {
|
||||
private static final Character FILTER_FIELD_DELIMITER = '|';
|
||||
|
||||
public CriteriaFiltering toDomain(CriteriaFilteringDto criteriaFilteringDto) {
|
||||
if (criteriaFilteringDto.filters().isEmpty()) {
|
||||
return CriteriaFiltering.empty();
|
||||
}
|
||||
|
||||
return new CriteriaFiltering(
|
||||
criteriaFilteringDto.filters().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(this::isNotBlankCriteria)
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toSet()));
|
||||
}
|
||||
|
||||
public Criteria toDomain(CriteriaDto criteriaDto) {
|
||||
final var operator = toOperator(criteriaDto.operator());
|
||||
|
||||
return new Criteria(
|
||||
toField(criteriaDto.field()), operator, toFilterValues(criteriaDto.value(), operator));
|
||||
}
|
||||
|
||||
protected Field toField(String value) {
|
||||
try {
|
||||
return new Field(value);
|
||||
} catch (IllegalDomainStateException _) {
|
||||
throw new BadCriteriaException("Invalid field '%s'".formatted(value));
|
||||
}
|
||||
}
|
||||
|
||||
protected CriteriaOperator toOperator(String value) {
|
||||
return switch (value.toLowerCase()) {
|
||||
case "between" -> CriteriaOperator.BETWEEN;
|
||||
case "eq" -> CriteriaOperator.EQ;
|
||||
case "gt" -> CriteriaOperator.GT;
|
||||
case "gte" -> CriteriaOperator.GTE;
|
||||
case "in" -> CriteriaOperator.IN;
|
||||
case "like" -> CriteriaOperator.LIKE;
|
||||
case "lt" -> CriteriaOperator.LT;
|
||||
case "lte" -> CriteriaOperator.LTE;
|
||||
case "neq" -> CriteriaOperator.NEQ;
|
||||
case "not_between" -> CriteriaOperator.NOT_BETWEEN;
|
||||
case "not_in" -> CriteriaOperator.NOT_IN;
|
||||
case "not_like" -> CriteriaOperator.NOT_LIKE;
|
||||
default -> throw new BadCriteriaException("Un handled operator '%s'".formatted(value));
|
||||
};
|
||||
}
|
||||
|
||||
protected List<FilterValue> toFilterValues(@Nullable String rawValue, CriteriaOperator operator) {
|
||||
if (rawValue == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
final var values =
|
||||
Arrays.stream(rawValue.split("\\%s".formatted(FILTER_FIELD_DELIMITER)))
|
||||
.map(String::trim)
|
||||
.filter(value -> !value.isBlank())
|
||||
.map(this::toFilterValue)
|
||||
.toList();
|
||||
|
||||
return switch (operator) {
|
||||
case BETWEEN, NOT_BETWEEN -> toRangeValues(values, operator);
|
||||
case IN, NOT_IN -> values;
|
||||
default -> values.isEmpty() ? List.of() : List.of(values.getFirst());
|
||||
};
|
||||
}
|
||||
|
||||
private List<FilterValue> toRangeValues(List<FilterValue> values, CriteriaOperator operator) {
|
||||
if (values.size() != 2) {
|
||||
throw new BadCriteriaException(
|
||||
"Operator '%s' expects exactly 2 values separated with '%s' delimiter"
|
||||
.formatted(operator, FILTER_FIELD_DELIMITER));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
protected FilterValue toFilterValue(String value) {
|
||||
try {
|
||||
return new FilterValue(value);
|
||||
} catch (IllegalDomainStateException _) {
|
||||
throw new BadCriteriaException("Invalid field value '%s'".formatted(value));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isNotBlankCriteria(CriteriaDto criteriaDto) {
|
||||
return !isBlank(criteriaDto.field())
|
||||
|| !isBlank(criteriaDto.operator())
|
||||
|| !isBlank(criteriaDto.value());
|
||||
}
|
||||
|
||||
private boolean isBlank(@Nullable String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.mappers;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.OrderingDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions.BadOrderingException;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.IllegalDomainStateException;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.Ordering;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.Ordering.Direction;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.Ordering.Field;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class OrderingMapper {
|
||||
public Ordering toDomain(OrderingDto orderingDto) {
|
||||
return new Ordering(toField(orderingDto.orderBy()), toDirection(orderingDto.direction()));
|
||||
}
|
||||
|
||||
protected Field toField(String value) {
|
||||
try {
|
||||
return new Field(value);
|
||||
} catch (IllegalDomainStateException _) {
|
||||
throw new BadOrderingException("Invalid field '%s'".formatted(value));
|
||||
}
|
||||
}
|
||||
|
||||
protected Direction toDirection(String value) {
|
||||
return switch (value.toLowerCase()) {
|
||||
case "asc" -> Direction.ASC;
|
||||
case "desc" -> Direction.DESC;
|
||||
default -> throw new BadOrderingException("Un handled direction '%s'".formatted(value));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.mappers;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.CriteriaFilteringDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.LocaleDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.OrderingDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.SocietyIdDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions.BadLocaleException;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions.BadSocietyIdException;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.IllegalDomainStateException;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.CriteriaFiltering;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.Locale;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.Ordering;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.SocietyFilter;
|
||||
import java.util.Optional;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class PaginatedRequestMapper {
|
||||
private final CriteriasMapper criteriasMapper;
|
||||
private final OrderingMapper orderingMapper;
|
||||
|
||||
public PaginatedRequestMapper(CriteriasMapper criteriasMapper, OrderingMapper orderingMapper) {
|
||||
this.criteriasMapper = criteriasMapper;
|
||||
this.orderingMapper = orderingMapper;
|
||||
}
|
||||
|
||||
public @Nullable CriteriaFiltering toDomain(@Nullable CriteriaFilteringDto value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return criteriasMapper.toDomain(value);
|
||||
}
|
||||
|
||||
public @Nullable Ordering toDomain(@Nullable OrderingDto value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final var orderBy = value.orderBy();
|
||||
final var direction = value.direction();
|
||||
|
||||
if (orderBy == null || direction == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (orderBy.isBlank() || direction.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return orderingMapper.toDomain(value);
|
||||
}
|
||||
|
||||
public @Nullable SocietyFilter toDomain(@Nullable SocietyIdDto value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final var societyId = value.society();
|
||||
|
||||
if (societyId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new SocietyFilter(societyId);
|
||||
} catch (IllegalDomainStateException _) {
|
||||
throw new BadSocietyIdException("Unhandled society id '%s'".formatted(societyId));
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable Locale toDomain(@Nullable LocaleDto value) {
|
||||
return Optional.ofNullable(value)
|
||||
.map(v -> v.locale())
|
||||
.filter(v -> v != null)
|
||||
.map(
|
||||
v ->
|
||||
switch (v.toLowerCase()) {
|
||||
case "fr" -> Locale.FR;
|
||||
case "en" -> Locale.EN;
|
||||
default ->
|
||||
throw new BadLocaleException(
|
||||
"Unhandled locale '%s'".formatted(value.locale()));
|
||||
})
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.mappers;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.PaginationDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.response.PageOfDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions.BadPaginationException;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.IllegalDomainStateException;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.lib.pagination.PageOf;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.lib.pagination.Pagination;
|
||||
import java.util.function.Function;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class PaginationMapper {
|
||||
|
||||
public Pagination toDomain(PaginationDto dto) {
|
||||
try {
|
||||
final var page = dto.page();
|
||||
final var perPage = dto.perPage();
|
||||
final var from = dto.from();
|
||||
final var to = dto.to();
|
||||
final var offset = dto.offset();
|
||||
final var limit = dto.limit();
|
||||
|
||||
if (page != null && perPage != null) {
|
||||
return Pagination.ofPage(page, perPage);
|
||||
} else if (from != null && to != null) {
|
||||
return Pagination.ofIndexes(from, to);
|
||||
} else if (offset != null && limit != null) {
|
||||
return Pagination.ofOffsetAndLimit(offset, limit);
|
||||
}
|
||||
|
||||
return Pagination.firstPage();
|
||||
} catch (IllegalDomainStateException e) {
|
||||
final var errorMessage = e.getMessage();
|
||||
throw new BadPaginationException(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public <E, T> PageOfDto<T> fromDomain(PageOf<E> paginatedData, Function<E, T> mapper) {
|
||||
|
||||
return new PageOfDto<>(
|
||||
paginatedData.items().stream().map(mapper).toList(),
|
||||
paginatedData.totalItems(),
|
||||
paginatedData.totalPages(),
|
||||
paginatedData.currentPage(),
|
||||
paginatedData.pageSize(),
|
||||
paginatedData.hasNextPage(),
|
||||
paginatedData.hasPreviousPage());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
@org.jspecify.annotations.NullMarked
|
||||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.mappers;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
@org.jspecify.annotations.NullMarked
|
||||
package com.ldc.arcadiagenesis.backendapi.application.apiv1;
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.routes.arcadiagenesis.employee;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.ApiErrorDetails;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.CriteriaFilteringDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.LocaleDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.OrderingDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.PaginationDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.SocietyIdDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.response.PageOfDto;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.mappers.PaginatedRequestMapper;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.mappers.PaginationMapper;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.services.api.ApiService.PaginateParams;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.services.api.EmployeeService;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.utils.DateUtils;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.ports.directory.views.EmployeeView;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.Optional;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("employee")
|
||||
public class ReadAllController {
|
||||
private final EmployeeService employeeService;
|
||||
private final PaginationMapper paginationMapper;
|
||||
private final PaginatedRequestMapper paginatedRequestMapper;
|
||||
|
||||
public ReadAllController(
|
||||
EmployeeService employeeService,
|
||||
PaginationMapper paginationMapper,
|
||||
PaginatedRequestMapper paginatedRequestMapper) {
|
||||
this.employeeService = employeeService;
|
||||
this.paginationMapper = paginationMapper;
|
||||
this.paginatedRequestMapper = paginatedRequestMapper;
|
||||
}
|
||||
|
||||
@Operation(
|
||||
operationId = "readEmployees",
|
||||
tags = {"Employee"},
|
||||
summary = "Paginate and filter employees",
|
||||
description =
|
||||
"Returns employees with pagination using page/perPage, from/to or offset/limit, and"
|
||||
+ " optional filters (e.g. filters[0].operator=between or in), ordering, locale and"
|
||||
+ " society.")
|
||||
@ApiResponses(
|
||||
value = {
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Employees returned successfully",
|
||||
content = @Content(schema = @Schema(implementation = EmployeePageDto.class))),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Invalid pagination, filtering or ordering parameters",
|
||||
content = @Content(schema = @Schema(implementation = ApiErrorDetails.class))),
|
||||
@ApiResponse(
|
||||
responseCode = "401",
|
||||
description = "Invalid or missing request signature headers",
|
||||
content = @Content(schema = @Schema(implementation = ApiErrorDetails.class)))
|
||||
})
|
||||
@GetMapping(
|
||||
value = "",
|
||||
produces = MediaType.APPLICATION_JSON_VALUE,
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<PageOfDto<EmployeeDto>> read(
|
||||
@Parameter(
|
||||
required = false,
|
||||
in = ParameterIn.QUERY,
|
||||
name = "Pagination",
|
||||
description =
|
||||
"Pagination params: page/perPage, from/to or offset/limit. "
|
||||
+ "Examples: page=1&perPage=10 or from=0&to=9")
|
||||
@Valid
|
||||
PaginationDto paginationDto,
|
||||
@Parameter(
|
||||
required = false,
|
||||
in = ParameterIn.QUERY,
|
||||
name = "Filtering",
|
||||
description =
|
||||
"Dynamic filters using filters[index].field/operator/value. "
|
||||
+ "Examples: filters[0].operator=between with 2 comma-separated values, "
|
||||
+ "or filters[0].operator=in with comma-separated values")
|
||||
@Valid
|
||||
CriteriaFilteringDto criteriasDto,
|
||||
@Parameter(
|
||||
required = false,
|
||||
in = ParameterIn.QUERY,
|
||||
name = "Ordering",
|
||||
description = "Ordering: orderBy plus direction (asc|desc)")
|
||||
@Valid
|
||||
OrderingDto orderingDto,
|
||||
@Parameter(
|
||||
required = false,
|
||||
in = ParameterIn.QUERY,
|
||||
name = "Locale",
|
||||
description = "Locale filter")
|
||||
@Valid
|
||||
LocaleDto localeDto,
|
||||
@Parameter(
|
||||
required = false,
|
||||
in = ParameterIn.QUERY,
|
||||
name = "Society",
|
||||
description = "Society filter")
|
||||
@Valid
|
||||
SocietyIdDto societyId) {
|
||||
final var pagination = paginationMapper.toDomain(paginationDto);
|
||||
final var criterias = paginatedRequestMapper.toDomain(criteriasDto);
|
||||
final var ordering = paginatedRequestMapper.toDomain(orderingDto);
|
||||
final var localeFilter = paginatedRequestMapper.toDomain(localeDto);
|
||||
final var societyFilter = paginatedRequestMapper.toDomain(societyId);
|
||||
final var paginateParams =
|
||||
new PaginateParams(pagination, criterias, ordering, localeFilter, societyFilter);
|
||||
|
||||
final var page = this.employeeService.paginate(paginateParams);
|
||||
final var response = ResponseEntity.ok();
|
||||
|
||||
return response.body(paginationMapper.fromDomain(page, this::toEmployeeDto));
|
||||
}
|
||||
|
||||
private EmployeeDto toEmployeeDto(EmployeeView employeeView) {
|
||||
return new EmployeeDto(
|
||||
employeeView.id().value(),
|
||||
employeeView.matricule(),
|
||||
employeeView.lastname(),
|
||||
employeeView.firstname(),
|
||||
DateUtils.toIso3339String(employeeView.startDate()),
|
||||
Optional.ofNullable(employeeView.endDate()).map(DateUtils::toIso3339String).orElse(null),
|
||||
Optional.ofNullable(employeeView.plannedEndDate())
|
||||
.map(DateUtils::toIso3339String)
|
||||
.orElse(null),
|
||||
LabeledCodeDto.of(
|
||||
employeeView.establishment().code(), employeeView.establishment().label()),
|
||||
LabeledCodeDto.of(employeeView.siteCode().code(), employeeView.siteCode().label()),
|
||||
LabeledCodeDto.of(employeeView.serviceCode().code(), employeeView.serviceCode().label()),
|
||||
DateUtils.toIso3339String(employeeView.createdAt()),
|
||||
DateUtils.toIso3339String(employeeView.updatedAt()));
|
||||
}
|
||||
|
||||
@Schema(name = "EmployeePageDto", description = "Paginated employees response")
|
||||
public record EmployeePageDto(
|
||||
@Schema(description = "Employees list") java.util.List<EmployeeDto> data,
|
||||
@Schema(description = "Total number of employees", example = "100") long totalElements,
|
||||
@Schema(description = "Total number of pages", example = "10") long totalPages,
|
||||
@Schema(description = "Current page number", example = "1") long page,
|
||||
@Schema(description = "Current page size", example = "10") long pageSize,
|
||||
@Schema(description = "True when a next page exists") boolean hasNext,
|
||||
@Schema(description = "True when a previous page exists") boolean hasPrevious) {}
|
||||
|
||||
@Schema(name = "EmployeeDto", description = "Employee response")
|
||||
public record EmployeeDto(
|
||||
@Schema(description = "Employee identifier", example = "1") String id,
|
||||
@Schema(description = "Employee matricule", example = "MATRICULE_1") String matricule,
|
||||
@Schema(description = "Employee lastname", example = "Lastname1") String lastname,
|
||||
@Schema(description = "Employee firstname", example = "Firstname1") String firstname,
|
||||
@Schema(description = "Start date in ISO-3339 format", example = "2025-01-01T00:00:00Z")
|
||||
String startDate,
|
||||
@Schema(
|
||||
description = "End date in ISO-3339 format",
|
||||
example = "2026-01-01T00:00:00Z",
|
||||
nullable = true)
|
||||
@Nullable String enddate,
|
||||
@Schema(
|
||||
description = "Planned end date in ISO-3339 format",
|
||||
example = "2026-06-01T00:00:00Z",
|
||||
nullable = true)
|
||||
@Nullable String plannedEndDate,
|
||||
@Schema(description = "Establishment code and label") LabeledCodeDto establishment,
|
||||
@Schema(description = "Site code and label") LabeledCodeDto siteCode,
|
||||
@Schema(description = "Service code and label") LabeledCodeDto serviceCode,
|
||||
@Schema(
|
||||
description = "Creation date in ISO-3339 format",
|
||||
example = "2026-04-21T10:00:00Z",
|
||||
nullable = true)
|
||||
@Nullable String createdAt,
|
||||
@Schema(
|
||||
description = "Update date in ISO-3339 format",
|
||||
example = "2026-04-21T12:00:00Z",
|
||||
nullable = true)
|
||||
@Nullable String updatedAt) {}
|
||||
|
||||
@Schema(name = "LabeledCodeDto", description = "Code/label pair")
|
||||
public record LabeledCodeDto(
|
||||
@Schema(description = "Code value", example = "SITE_1") String code,
|
||||
@Schema(description = "Label value", example = "Site 1", nullable = true)
|
||||
@Nullable String label) {
|
||||
public static LabeledCodeDto of(String code, @Nullable String label) {
|
||||
return new LabeledCodeDto(code, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.routes.arcadiagenesis.employee;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.ApiErrorDetails;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions.BadRequestException;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.exceptions.NotFoundException;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.services.api.EmployeeService;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.utils.DateUtils;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.IllegalDomainStateException;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.entity.Employee;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.entity.EmployeeId;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.Optional;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("employee")
|
||||
public class ReadController {
|
||||
private final EmployeeService employeeService;
|
||||
|
||||
public ReadController(EmployeeService employeeService) {
|
||||
this.employeeService = employeeService;
|
||||
}
|
||||
|
||||
@Operation(
|
||||
operationId = "readEmployee",
|
||||
tags = {"Employee"},
|
||||
summary = "Get an employee by id",
|
||||
description = "Returns one employee for the given identifier")
|
||||
@ApiResponses(
|
||||
value = {
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Employee found successfully",
|
||||
content = @Content(schema = @Schema(implementation = EmployeeDto.class))),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Invalid employee ID format",
|
||||
content = @Content(schema = @Schema(implementation = ApiErrorDetails.class))),
|
||||
@ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "Employee not found",
|
||||
content = @Content(schema = @Schema(implementation = ApiErrorDetails.class))),
|
||||
@ApiResponse(
|
||||
responseCode = "401",
|
||||
description = "Invalid or missing request signature headers",
|
||||
content = @Content(schema = @Schema(implementation = ApiErrorDetails.class)))
|
||||
})
|
||||
@GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<EmployeeDto> read(
|
||||
@Parameter(description = "Employee identifier", example = "1")
|
||||
@Validated
|
||||
@PathVariable
|
||||
@NotNull
|
||||
String id) {
|
||||
final var employeeId = toEmployeeId(id);
|
||||
final var employee = this.employeeService.getBy(employeeId);
|
||||
|
||||
if (employee == null) {
|
||||
throw new NotFoundException("Employee not found");
|
||||
}
|
||||
|
||||
final var response = ResponseEntity.ok();
|
||||
return response.body(toEmployeeDto(employee));
|
||||
}
|
||||
|
||||
private EmployeeId toEmployeeId(@Nullable String employeIdValue) {
|
||||
try {
|
||||
if (employeIdValue == null || employeIdValue.isBlank()) {
|
||||
throw new BadRequestException("Employee ID cannot be empty");
|
||||
}
|
||||
|
||||
return new EmployeeId(employeIdValue);
|
||||
} catch (IllegalDomainStateException e) {
|
||||
throw new BadRequestException("Invalid employee ID format", e);
|
||||
}
|
||||
}
|
||||
|
||||
private EmployeeDto toEmployeeDto(Employee employee) {
|
||||
return new EmployeeDto(
|
||||
employee.getId().value(),
|
||||
employee.getMatricule(),
|
||||
employee.getLastname(),
|
||||
employee.getFirstname(),
|
||||
DateUtils.toIso3339String(employee.getStartDate()),
|
||||
Optional.ofNullable(employee.getEndDate()).map(DateUtils::toIso3339String).orElse(null),
|
||||
Optional.ofNullable(employee.getPlannedEndDate())
|
||||
.map(DateUtils::toIso3339String)
|
||||
.orElse(null),
|
||||
LabeledCodeDto.of(employee.getEstablishment().code(), employee.getEstablishment().label()),
|
||||
LabeledCodeDto.of(employee.getSiteCode().code(), employee.getSiteCode().label()),
|
||||
LabeledCodeDto.of(employee.getServiceCode().code(), employee.getServiceCode().label()),
|
||||
DateUtils.toIso3339String(employee.getCreatedAt()),
|
||||
DateUtils.toIso3339String(employee.getUpdatedAt()));
|
||||
}
|
||||
|
||||
@Schema(name = "EmployeeDto", description = "Employee response")
|
||||
public record EmployeeDto(
|
||||
@Schema(description = "Employee identifier", example = "1") String id,
|
||||
@Schema(description = "Employee matricule", example = "MATRICULE_1") String matricule,
|
||||
@Schema(description = "Employee lastname", example = "Lastname1") String lastname,
|
||||
@Schema(description = "Employee firstname", example = "Firstname1") String firstname,
|
||||
@Schema(description = "Start date in ISO-3339 format", example = "2025-01-01T00:00:00Z")
|
||||
String startDate,
|
||||
@Schema(
|
||||
description = "End date in ISO-3339 format",
|
||||
example = "2026-01-01T00:00:00Z",
|
||||
nullable = true)
|
||||
@Nullable String enddate,
|
||||
@Schema(
|
||||
description = "Planned end date in ISO-3339 format",
|
||||
example = "2026-06-01T00:00:00Z",
|
||||
nullable = true)
|
||||
@Nullable String plannedEndDate,
|
||||
@Schema(description = "Establishment code and label") LabeledCodeDto establishment,
|
||||
@Schema(description = "Site code and label") LabeledCodeDto siteCode,
|
||||
@Schema(description = "Service code and label") LabeledCodeDto serviceCode,
|
||||
@Schema(
|
||||
description = "Creation date in ISO-3339 format",
|
||||
example = "2026-04-21T10:00:00Z",
|
||||
nullable = true)
|
||||
@Nullable String createdAt,
|
||||
@Schema(
|
||||
description = "Update date in ISO-3339 format",
|
||||
example = "2026-04-21T12:00:00Z",
|
||||
nullable = true)
|
||||
@Nullable String updatedAt) {}
|
||||
|
||||
@Schema(name = "LabeledCodeDto", description = "Code/label pair")
|
||||
public record LabeledCodeDto(
|
||||
@Schema(description = "Code value", example = "SITE_1") String code,
|
||||
@Schema(description = "Label value", example = "Site 1", nullable = true)
|
||||
@Nullable String label) {
|
||||
public static LabeledCodeDto of(String code, @Nullable String label) {
|
||||
return new LabeledCodeDto(code, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
@org.jspecify.annotations.NullMarked
|
||||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.routes.arcadiagenesis.employee;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
@org.jspecify.annotations.NullMarked
|
||||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.routes;
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.seed;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.services.api.EmployeeService;
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.services.api.EmployeeService.EmployeeCreation;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.TimelinePoint;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.EstablishementCode;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.ServiceCode;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.SiteCode;
|
||||
import java.time.Period;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.random.RandomGenerator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class EmployeeSeeder implements Seeder {
|
||||
private static final Logger logger = LoggerFactory.getLogger(EmployeeSeeder.class);
|
||||
|
||||
private static final int NUMBER_OF_DEMO_SITES = 5;
|
||||
private static final int NUMBER_OF_DEMO_ESTABLISHMENT = 5;
|
||||
private static final int NUMBER_OF_DEMO_SERVICES = 5;
|
||||
private static final int NUMBER_OF_DEMO_EMPLOYEES = 100;
|
||||
|
||||
private final List<EstablishementCode> establishmentCodes = new ArrayList<>();
|
||||
private final List<SiteCode> siteCodes = new ArrayList<>();
|
||||
private final List<ServiceCode> serviceCodes = new ArrayList<>();
|
||||
|
||||
private final EmployeeService employeeService;
|
||||
|
||||
public EmployeeSeeder(EmployeeService employeeService) {
|
||||
this.employeeService = employeeService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void seed() {
|
||||
seedDemoServices();
|
||||
seedDemoEstablishments();
|
||||
seedDemoSites();
|
||||
seedDemoEmployees();
|
||||
}
|
||||
|
||||
private void seedDemoEmployees() {
|
||||
final var randomGen = RandomGenerator.getDefault();
|
||||
|
||||
for (int i = 1; i <= NUMBER_OF_DEMO_EMPLOYEES; i++) {
|
||||
final var randomMatricule = "MATRICULE_" + i;
|
||||
final var randomLastname = "Lastname" + i;
|
||||
final var randomFirstname = "Firstname" + i;
|
||||
final var randomStartDate =
|
||||
TimelinePoint.now().minus(Period.ofDays(randomGen.nextInt(365 * 10)));
|
||||
final var randomEndDate =
|
||||
randomGen.nextBoolean()
|
||||
? null
|
||||
: randomStartDate.plus(Period.ofDays(randomGen.nextInt(365 * 10)));
|
||||
final var plannedEndDateBase = randomEndDate != null ? randomEndDate : randomStartDate;
|
||||
final var randomPlannedEndDate =
|
||||
randomGen.nextBoolean()
|
||||
? null
|
||||
: plannedEndDateBase.plus(Period.ofDays(randomGen.nextInt(365 * 5)));
|
||||
|
||||
final var randomEstablishmentCode =
|
||||
establishmentCodes.get(randomGen.nextInt(establishmentCodes.size()));
|
||||
final var randomSiteCode = siteCodes.get(randomGen.nextInt(siteCodes.size()));
|
||||
final var randomServiceCode = serviceCodes.get(randomGen.nextInt(serviceCodes.size()));
|
||||
|
||||
employeeService.create(
|
||||
new EmployeeCreation(
|
||||
randomMatricule,
|
||||
randomLastname,
|
||||
randomFirstname,
|
||||
randomStartDate,
|
||||
randomEndDate,
|
||||
randomPlannedEndDate,
|
||||
randomEstablishmentCode,
|
||||
randomSiteCode,
|
||||
randomServiceCode));
|
||||
logger.info(
|
||||
"Seeded demo employee with matricule: {}, name: {} {}, start date: {}, end date: {},"
|
||||
+ " planned end date: {}, establishment code: {}, site code: {}, service code: {}",
|
||||
randomMatricule,
|
||||
randomFirstname,
|
||||
randomLastname,
|
||||
randomStartDate,
|
||||
randomEndDate,
|
||||
randomPlannedEndDate,
|
||||
randomEstablishmentCode.code(),
|
||||
randomSiteCode.code(),
|
||||
randomServiceCode.code());
|
||||
}
|
||||
}
|
||||
|
||||
private void seedDemoEstablishments() {
|
||||
for (int i = 1; i <= NUMBER_OF_DEMO_ESTABLISHMENT; i++) {
|
||||
final var randomCode = "ESTABLISHMENT_" + i;
|
||||
final var label = "Establishment " + i;
|
||||
final var establishmentCode = new EstablishementCode(randomCode, label);
|
||||
establishmentCodes.add(establishmentCode);
|
||||
logger.info("Seeded demo establishment with code: {} and label: {}", randomCode, label);
|
||||
}
|
||||
}
|
||||
|
||||
private void seedDemoServices() {
|
||||
for (int i = 1; i <= NUMBER_OF_DEMO_SERVICES; i++) {
|
||||
final var randomCode = "CODE_" + i;
|
||||
final var label = "Service " + i;
|
||||
final var serviceCode = new ServiceCode(randomCode, label);
|
||||
serviceCodes.add(serviceCode);
|
||||
logger.info("Seeded demo service with code: {} and label: {}", randomCode, label);
|
||||
}
|
||||
}
|
||||
|
||||
private void seedDemoSites() {
|
||||
for (int i = 1; i <= NUMBER_OF_DEMO_SITES; i++) {
|
||||
final var randomCode = "SITE_" + i;
|
||||
final var label = "Site " + i;
|
||||
final var siteCode = new SiteCode(randomCode, label);
|
||||
siteCodes.add(siteCode);
|
||||
logger.info("Seeded demo site with code: {} and label: {}", randomCode, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.seed;
|
||||
|
||||
public interface Seeder {
|
||||
void seed();
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
@org.jspecify.annotations.NullMarked
|
||||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.seed;
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.services;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.services.apisecurity.ApiSecret;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class RequestSignatureService {
|
||||
|
||||
private static final String HMAC_ALGORITHM = "HmacSHA256";
|
||||
|
||||
public boolean isSignatureValid(
|
||||
Request request, String signature, Instant timestamp, UUID nonce, ApiSecret secret) {
|
||||
final var params = parseQueryString(request.queryString());
|
||||
|
||||
final var expected =
|
||||
calculateSignature(
|
||||
request.httpMethod(),
|
||||
request.scheme(),
|
||||
request.host(),
|
||||
request.path(),
|
||||
params,
|
||||
timestamp,
|
||||
nonce,
|
||||
request.body(),
|
||||
secret);
|
||||
|
||||
return MessageDigest.isEqual(
|
||||
signature.getBytes(StandardCharsets.UTF_8), expected.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
String calculateSignature(
|
||||
String httpMethod,
|
||||
String scheme,
|
||||
String host,
|
||||
String path,
|
||||
Map<String, String> dataParams,
|
||||
Instant timestamp,
|
||||
UUID nonce,
|
||||
@Nullable String body,
|
||||
ApiSecret secret) {
|
||||
|
||||
final var normalizedBody = normalizeBody(body);
|
||||
|
||||
final var contentSha256 =
|
||||
!normalizedBody.isEmpty() ? Base64.getEncoder().encodeToString(sha256(normalizedBody)) : "";
|
||||
|
||||
final var queryString = buildQueryString(new TreeMap<>(dataParams));
|
||||
|
||||
final var normalizedTimestamp = toRfc3999(timestamp);
|
||||
|
||||
final var stringToSign =
|
||||
String.join(
|
||||
"\n",
|
||||
httpMethod.toUpperCase(),
|
||||
scheme.toLowerCase() + "://" + host.toLowerCase(),
|
||||
path,
|
||||
queryString,
|
||||
normalizedTimestamp,
|
||||
nonce.toString(),
|
||||
contentSha256);
|
||||
|
||||
return Base64.getEncoder().encodeToString(hmacSha256(secret.value(), stringToSign));
|
||||
}
|
||||
|
||||
static TreeMap<String, String> parseQueryString(@Nullable String queryString) {
|
||||
final var params = new TreeMap<String, String>();
|
||||
if (queryString == null || queryString.isBlank()) {
|
||||
return params;
|
||||
}
|
||||
for (final var pair : queryString.split("&")) {
|
||||
final var parts = pair.split("=", 2);
|
||||
final var key = URLDecoder.decode(parts[0], StandardCharsets.UTF_8);
|
||||
final var value = parts.length > 1 ? URLDecoder.decode(parts[1], StandardCharsets.UTF_8) : "";
|
||||
params.put(key, value);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
private static String toRfc3999(Instant timestamp) {
|
||||
return timestamp
|
||||
.atOffset(ZoneOffset.UTC)
|
||||
.truncatedTo(ChronoUnit.SECONDS)
|
||||
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
|
||||
}
|
||||
|
||||
private static String normalizeBody(@Nullable String body) {
|
||||
if (body == null || body.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
var normalized = body;
|
||||
if (normalized.charAt(0) == '\uFEFF') {
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
if (normalized.endsWith("\r\n")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 2);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static String buildQueryString(TreeMap<String, String> params) {
|
||||
final var sb = new StringBuilder();
|
||||
for (final var entry : params.entrySet()) {
|
||||
if (!sb.isEmpty()) {
|
||||
sb.append('&');
|
||||
}
|
||||
sb.append(encodeRfc3986(entry.getKey()));
|
||||
sb.append('=');
|
||||
sb.append(encodeRfc3986(entry.getValue()));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String encodeRfc3986(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8)
|
||||
.replace("+", "%20")
|
||||
.replace("*", "%2A")
|
||||
.replace("%7E", "~");
|
||||
}
|
||||
|
||||
private static byte[] sha256(String input) {
|
||||
try {
|
||||
final var digest = MessageDigest.getInstance("SHA-256");
|
||||
return digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] hmacSha256(String key, String data) {
|
||||
try {
|
||||
final var mac = Mac.getInstance(HMAC_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM));
|
||||
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("HMAC-SHA256 not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
public record Request(
|
||||
String httpMethod,
|
||||
String contentType,
|
||||
String scheme,
|
||||
String host,
|
||||
String path,
|
||||
String queryString,
|
||||
String body) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.services.api;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.DataView;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.Entity;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.EntityId;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.lib.pagination.PageOf;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.lib.pagination.Pagination;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.CriteriaFiltering;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.Locale;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.Ordering;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.SocietyFilter;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
public interface ApiService<E extends Entity, D extends DataView, I extends EntityId> {
|
||||
public PageOf<D> paginate(PaginateParams params);
|
||||
|
||||
public @Nullable E getBy(I entityId);
|
||||
|
||||
public record PaginateParams(
|
||||
Pagination pagination,
|
||||
@Nullable CriteriaFiltering criteriaFiltering,
|
||||
@Nullable Ordering ordering,
|
||||
@Nullable Locale locale,
|
||||
@Nullable SocietyFilter societyFilter) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.services.api;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.TimelinePoint;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.lib.pagination.PageOf;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.entity.Employee;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.EstablishementCode;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.ServiceCode;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.SiteCode;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.entity.EmployeeId;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.models.valueobject.query.DirectoryAdditionalParams;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.ports.directory.EmployeeDirectory;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.ports.directory.views.EmployeeView;
|
||||
import com.ldc.arcadiagenesis.backendapi.domain.ports.repository.EmployeeRepository;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class EmployeeService implements ApiService<Employee, EmployeeView, EmployeeId> {
|
||||
private final EmployeeRepository employeeRepository;
|
||||
private final EmployeeDirectory employeeDirectory;
|
||||
|
||||
public EmployeeService(
|
||||
EmployeeRepository employeeRepository, EmployeeDirectory employeeDirectory) {
|
||||
this.employeeRepository = employeeRepository;
|
||||
this.employeeDirectory = employeeDirectory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageOf<EmployeeView> paginate(PaginateParams params) {
|
||||
return employeeDirectory.paginate(
|
||||
params.pagination(),
|
||||
new DirectoryAdditionalParams(
|
||||
params.criteriaFiltering(),
|
||||
params.ordering(),
|
||||
params.locale(),
|
||||
params.societyFilter()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Employee getBy(EmployeeId id) {
|
||||
return employeeRepository.find(id).orElse(null);
|
||||
}
|
||||
|
||||
public boolean create(EmployeeCreation creation) {
|
||||
final var existingEmployee = employeeRepository.findByMatricule(creation.matricule);
|
||||
|
||||
if (existingEmployee.isPresent()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final var newEmployee =
|
||||
new Employee(
|
||||
creation.matricule,
|
||||
creation.lastname,
|
||||
creation.firstname,
|
||||
creation.startDate,
|
||||
creation.enddate,
|
||||
creation.plannedEndDate,
|
||||
creation.establishment,
|
||||
creation.siteCode,
|
||||
creation.serviceCode);
|
||||
|
||||
employeeRepository.persist(newEmployee);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public record EmployeeCreation(
|
||||
String matricule,
|
||||
String lastname,
|
||||
String firstname,
|
||||
TimelinePoint startDate,
|
||||
@Nullable TimelinePoint enddate,
|
||||
@Nullable TimelinePoint plannedEndDate,
|
||||
EstablishementCode establishment,
|
||||
SiteCode siteCode,
|
||||
ServiceCode serviceCode) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.services.apisecurity;
|
||||
|
||||
public record ApiKey(String value) {
|
||||
public ApiKey {
|
||||
if (value.isBlank()) {
|
||||
throw new IllegalArgumentException("API key cannot be blank");
|
||||
}
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return "SentiveString API KEY";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.services.apisecurity;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ApiKeyStore {
|
||||
public Optional<ApiSecret> findSecretByApiKey(ApiKey apiKey);
|
||||
|
||||
public boolean isValidApiKey(ApiKey apiKey);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.services.apisecurity;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.IllegalDomainStateException;
|
||||
import com.ldc.arcadiagenesis.backendapi.common.lib.utils.UuidV7;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ApiNonce(UUID value) {
|
||||
public ApiNonce {
|
||||
assertIsValidUuidV7(value);
|
||||
}
|
||||
|
||||
public static ApiNonce of(String value) {
|
||||
return new ApiNonce(toUuidV7(value));
|
||||
}
|
||||
|
||||
private static UUID toUuidV7(String value) {
|
||||
final var uuid = UUID.fromString(value);
|
||||
assertIsValidUuidV7(uuid);
|
||||
return uuid;
|
||||
}
|
||||
|
||||
private static void assertIsValidUuidV7(UUID value) {
|
||||
try {
|
||||
UuidV7.extractInstant(value);
|
||||
} catch (IllegalArgumentException _) {
|
||||
throw new IllegalDomainStateException(
|
||||
"Invalid nonce format: %s. Nonce must be a valid UUIDv7 string.".formatted(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.services.apisecurity;
|
||||
|
||||
interface ApiNonceStore {
|
||||
public void storeNonce(ApiKey apiKey, ApiNonce nonce);
|
||||
|
||||
public boolean hasBeenUsed(ApiKey apiKey, ApiNonce nonce);
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.services.apisecurity;
|
||||
|
||||
public record ApiSecret(String value) {
|
||||
public ApiSecret {
|
||||
if (value.isBlank()) {
|
||||
throw new IllegalArgumentException("API key cannot be blank");
|
||||
}
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return "SentiveString API SECRET";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.services.apisecurity;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* In-memory store of API client credentials. Each entry maps an API key to its corresponding
|
||||
* HMAC-SHA256 secret.
|
||||
*
|
||||
* <p>This is intentionally hardcoded for the demo. A real implementation would back this with a
|
||||
* database or a secrets manager.
|
||||
*/
|
||||
@Component
|
||||
public class InMemoryApiKeyStore implements ApiKeyStore {
|
||||
private static final Map<ApiKey, ApiSecret> store =
|
||||
Map.of(
|
||||
new ApiKey("LdcDigitalService"),
|
||||
new ApiSecret("SUPERSECRET"),
|
||||
new ApiKey("External1"),
|
||||
new ApiSecret("TESTSECRET"));
|
||||
|
||||
public Optional<ApiSecret> findSecretByApiKey(ApiKey apiKey) {
|
||||
return Optional.ofNullable(store.get(apiKey));
|
||||
}
|
||||
|
||||
public boolean isValidApiKey(ApiKey apiKey) {
|
||||
return store.containsKey(apiKey);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.services.apisecurity;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* In-memory store of API client credentials. Each entry maps an API key to its corresponding
|
||||
* HMAC-SHA256 secret.
|
||||
*
|
||||
* <p>This is intentionally hardcoded for the demo. A real implementation would back this with a
|
||||
* database or a secrets manager.
|
||||
*/
|
||||
@Component
|
||||
public class InMemoryApiNonceStore implements ApiNonceStore {
|
||||
private static final Map<ApiKey, ApiNonce> store = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void storeNonce(ApiKey apiKey, ApiNonce nonce) {
|
||||
store.put(apiKey, nonce);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasBeenUsed(ApiKey apiKey, ApiNonce nonce) {
|
||||
return !store.get(apiKey).equals(nonce);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
@org.jspecify.annotations.NullMarked
|
||||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.services;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.utils;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.TimelinePoint;
|
||||
import java.time.ZoneId;
|
||||
|
||||
public abstract class DateUtils {
|
||||
private static final ZoneId ZONE_ID = ZoneId.of("UTC");
|
||||
|
||||
private DateUtils() {}
|
||||
|
||||
public static String toIso3339String(TimelinePoint date) {
|
||||
return date.atZone(ZONE_ID).toOffsetDateTime().toString();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.validation;
|
||||
|
||||
import jakarta.validation.Constraint;
|
||||
import jakarta.validation.Payload;
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Documented
|
||||
@Constraint(validatedBy = PaginationValidator.class)
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface PaginationRange {
|
||||
String message() default "Either page/perPage or from/to must be provided";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.validation;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.PaginationDto;
|
||||
import jakarta.validation.ConstraintValidator;
|
||||
import jakarta.validation.ConstraintValidatorContext;
|
||||
|
||||
public class PaginationValidator implements ConstraintValidator<PaginationRange, PaginationDto> {
|
||||
@Override
|
||||
public boolean isValid(PaginationDto dto, ConstraintValidatorContext context) {
|
||||
final var hasPageRange = dto.page() != null && dto.perPage() != null;
|
||||
final var hasIndexRange = dto.from() != null && dto.to() != null;
|
||||
|
||||
context.disableDefaultConstraintViolation();
|
||||
|
||||
if (hasPageRange && hasIndexRange) {
|
||||
context
|
||||
.buildConstraintViolationWithTemplate("Provide either page/perPage or from/to, not both")
|
||||
.addConstraintViolation();
|
||||
return false;
|
||||
}
|
||||
|
||||
context
|
||||
.buildConstraintViolationWithTemplate(
|
||||
"Provide any of page/perPage or from/to for pagination")
|
||||
.addConstraintViolation();
|
||||
|
||||
return hasPageRange || hasIndexRange || (!hasPageRange && !hasIndexRange);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.validation;
|
||||
|
||||
import jakarta.validation.Constraint;
|
||||
import jakarta.validation.Payload;
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Documented
|
||||
@Constraint(validatedBy = ValidCriteriaOperatorValidator.class)
|
||||
@Target({ElementType.FIELD, ElementType.PARAMETER})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ValidCriteriaOperator {
|
||||
String message() default "Invalid criteria operator";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.validation;
|
||||
|
||||
import jakarta.validation.ConstraintValidator;
|
||||
import jakarta.validation.ConstraintValidatorContext;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
public class ValidCriteriaOperatorValidator
|
||||
implements ConstraintValidator<ValidCriteriaOperator, String> {
|
||||
|
||||
private static final Set<String> ALLOWED =
|
||||
Arrays.stream(CriteriaOperatorValue.values())
|
||||
.map(Enum::name)
|
||||
.map(String::toLowerCase)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
@Override
|
||||
public boolean isValid(@Nullable String value, ConstraintValidatorContext context) {
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
return ALLOWED.contains(value.toLowerCase());
|
||||
}
|
||||
|
||||
public enum CriteriaOperatorValue {
|
||||
EQ,
|
||||
NEQ,
|
||||
GT,
|
||||
GTE,
|
||||
LT,
|
||||
LTE,
|
||||
BETWEEN,
|
||||
NOT_BETWEEN,
|
||||
LIKE,
|
||||
NOT_LIKE,
|
||||
IN,
|
||||
NOT_IN
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.validation;
|
||||
|
||||
import jakarta.validation.Constraint;
|
||||
import jakarta.validation.Payload;
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Documented
|
||||
@Constraint(validatedBy = ValidLocaleValidator.class)
|
||||
@Target({ElementType.FIELD, ElementType.PARAMETER})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ValidLocale {
|
||||
String message() default "Invalid locale: must be one of the supported values";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.validation;
|
||||
|
||||
import jakarta.validation.ConstraintValidator;
|
||||
import jakarta.validation.ConstraintValidatorContext;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
public class ValidLocaleValidator implements ConstraintValidator<ValidLocale, String> {
|
||||
|
||||
private static final Set<String> ALLOWED =
|
||||
Arrays.stream(LocaleValue.values())
|
||||
.map(Enum::name)
|
||||
.map(String::toLowerCase)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
@Override
|
||||
public boolean isValid(@Nullable String value, ConstraintValidatorContext context) {
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
return ALLOWED.contains(value.toLowerCase());
|
||||
}
|
||||
|
||||
public enum LocaleValue {
|
||||
FR,
|
||||
EN
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.validation;
|
||||
|
||||
import jakarta.validation.Constraint;
|
||||
import jakarta.validation.Payload;
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Documented
|
||||
@Constraint(validatedBy = ValidOrderingValidator.class)
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ValidOrdering {
|
||||
String message() default "Invalid ordering";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.validation;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request.OrderingDto;
|
||||
import jakarta.validation.ConstraintValidator;
|
||||
import jakarta.validation.ConstraintValidatorContext;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
public class ValidOrderingValidator implements ConstraintValidator<ValidOrdering, OrderingDto> {
|
||||
|
||||
private static final Set<String> ALLOWED =
|
||||
Arrays.stream(OrderingDirectionValue.values())
|
||||
.map(Enum::name)
|
||||
.map(String::toLowerCase)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
@Override
|
||||
public boolean isValid(@Nullable OrderingDto value, ConstraintValidatorContext context) {
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final var orderBy = value.orderBy();
|
||||
final var direction = value.direction();
|
||||
|
||||
if (orderBy == null && direction == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (orderBy == null || orderBy.isBlank()) {
|
||||
context
|
||||
.buildConstraintViolationWithTemplate("orderBy must not be blank")
|
||||
.addPropertyNode("orderBy")
|
||||
.addConstraintViolation()
|
||||
.disableDefaultConstraintViolation();
|
||||
return false;
|
||||
}
|
||||
if (direction == null || direction.isBlank()) {
|
||||
context
|
||||
.buildConstraintViolationWithTemplate("direction must not be blank")
|
||||
.addPropertyNode("direction")
|
||||
.addConstraintViolation()
|
||||
.disableDefaultConstraintViolation();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ALLOWED.contains(direction.toLowerCase())) {
|
||||
context
|
||||
.buildConstraintViolationWithTemplate(
|
||||
"Invalid ordering direction '%s', allowed values are %s"
|
||||
.formatted(direction, ALLOWED))
|
||||
.addPropertyNode("direction")
|
||||
.addConstraintViolation()
|
||||
.disableDefaultConstraintViolation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public enum OrderingDirectionValue {
|
||||
NATURAL,
|
||||
ASC,
|
||||
DESC
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
@org.jspecify.annotations.NullMarked
|
||||
package com.ldc.arcadiagenesis.backendapi.application.apiv1.validation;
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.dependencies;
|
||||
|
||||
import com.ldc.arcadiagenesis.backendapi.common.ddd.Repository;
|
||||
import org.aopalliance.intercept.MethodInterceptor;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Wraps every {@link Repository} bean with a JDK dynamic proxy that applies the registered {@link
|
||||
* RepositoryMethodInterceptor}.
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class RepositoryProxy implements BeanPostProcessor {
|
||||
private final ObjectProvider<RepositoryMethodInterceptor> interceptorProvider;
|
||||
private @Nullable MethodInterceptor interceptor;
|
||||
|
||||
public RepositoryProxy(ObjectProvider<RepositoryMethodInterceptor> interceptorProvider) {
|
||||
this.interceptorProvider = interceptorProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object postProcessAfterInitialization(@Nullable Object bean, @Nullable String beanName)
|
||||
throws BeansException {
|
||||
|
||||
if (bean == null) {
|
||||
throw new IllegalArgumentException("Bean cannot be null");
|
||||
}
|
||||
|
||||
if (beanName == null) {
|
||||
throw new IllegalArgumentException("Bean name cannot be null");
|
||||
}
|
||||
|
||||
if (!(bean instanceof Repository)) {
|
||||
return bean;
|
||||
}
|
||||
|
||||
final var repositoryInterceptor = resolveInterceptor();
|
||||
|
||||
final var proxyFactory = new ProxyFactory(bean);
|
||||
|
||||
// * JDK dynamic proxy (interfaces)
|
||||
proxyFactory.setProxyTargetClass(false);
|
||||
proxyFactory.addAdvice(repositoryInterceptor);
|
||||
|
||||
return proxyFactory.getProxy();
|
||||
}
|
||||
|
||||
private MethodInterceptor resolveInterceptor() {
|
||||
if (interceptor != null) {
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
final var candidate = interceptorProvider.getIfAvailable();
|
||||
if (candidate == null) {
|
||||
throw new IllegalStateException("No RepositoryMethodInterceptor bean found");
|
||||
}
|
||||
|
||||
if (!(candidate instanceof MethodInterceptor methodInterceptor)) {
|
||||
throw new IllegalStateException(
|
||||
"RepositoryMethodInterceptor must also implement MethodInterceptor");
|
||||
}
|
||||
|
||||
interceptor = methodInterceptor;
|
||||
return methodInterceptor;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package com.ldc.arcadiagenesis.backendapi.application.dependencies;
|
||||
|
||||
import org.aopalliance.intercept.MethodInterceptor;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.DefaultTransactionDefinition;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Wraps every {@link RestController} bean with a CGLIB proxy that automatically opens a transaction
|
||||
* around each handler method invocation.
|
||||
*
|
||||
* <p>The proxy is only applied when a {@link PlatformTransactionManager} is available in the
|
||||
* context. When running with in-memory repositories (no database), controllers behave normally.
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.LOWEST_PRECEDENCE)
|
||||
public class TransactionalControllerProxy implements BeanPostProcessor {
|
||||
private static final Logger logger = LoggerFactory.getLogger(TransactionalControllerProxy.class);
|
||||
|
||||
private final ObjectProvider<PlatformTransactionManager> txManagerProvider;
|
||||
|
||||
public TransactionalControllerProxy(
|
||||
ObjectProvider<PlatformTransactionManager> txManagerProvider) {
|
||||
this.txManagerProvider = txManagerProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object postProcessAfterInitialization(@Nullable Object bean, @Nullable String beanName)
|
||||
throws BeansException {
|
||||
|
||||
if (bean == null) {
|
||||
throw new IllegalArgumentException("Bean cannot be null");
|
||||
}
|
||||
|
||||
if (beanName == null) {
|
||||
throw new IllegalArgumentException("Bean name cannot be null");
|
||||
}
|
||||
|
||||
final var txManager = txManagerProvider.getIfAvailable();
|
||||
if (txManager == null) {
|
||||
return bean;
|
||||
}
|
||||
|
||||
if (AnnotationUtils.findAnnotation(bean.getClass(), RestController.class) == null) {
|
||||
return bean;
|
||||
}
|
||||
|
||||
final var proxyFactory = new ProxyFactory(bean);
|
||||
|
||||
// CGLIB proxy (controllers are classes, not interfaces)
|
||||
proxyFactory.setProxyTargetClass(true);
|
||||
proxyFactory.addAdvice(transactionalAdvice(txManager));
|
||||
|
||||
return proxyFactory.getProxy();
|
||||
}
|
||||
|
||||
private MethodInterceptor transactionalAdvice(PlatformTransactionManager txManager) {
|
||||
return invocation -> {
|
||||
// Only wrap actual handler methods (annotated with @RequestMapping or its aliases)
|
||||
if (AnnotationUtils.findAnnotation(invocation.getMethod(), RequestMapping.class) == null) {
|
||||
return invocation.proceed();
|
||||
}
|
||||
|
||||
final var method = invocation.getMethod();
|
||||
logger.info(
|
||||
"Api call on {}.{}()", method.getDeclaringClass().getSimpleName(), method.getName());
|
||||
|
||||
final var status = txManager.getTransaction(new DefaultTransactionDefinition());
|
||||
try {
|
||||
final var result = invocation.proceed();
|
||||
txManager.commit(status);
|
||||
return result;
|
||||
} catch (Throwable ex) {
|
||||
txManager.rollback(status);
|
||||
throw ex;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue