Compare commits

..

No commits in common. "implement-demonstrator" and "main" have entirely different histories.

185 changed files with 126 additions and 6801 deletions

View file

@ -1,8 +0,0 @@
.git
.gitignore
.vscode
.idea
.target
**/target
README.md
TODO.md

View file

@ -1,10 +0,0 @@
# 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

View file

@ -1,28 +0,0 @@
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
View file

@ -66,6 +66,3 @@ buildNumber.properties
.ionide
# End of https://www.toptal.com/developers/gitignore/api/maven,java,visualstudiocode
# Local Docker Compose environment overrides
.env

View file

@ -1,10 +0,0 @@
--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

View file

@ -1,13 +0,0 @@
{
"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
View file

@ -1,14 +1,14 @@
{
"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"
}
]
"configurations": [
{
"type": "java",
"name": "Spring Boot-BackendapiApplication<backendapi>",
"request": "launch",
"cwd": "${workspaceFolder}",
"mainClass": "com.ldc.arcadiagenesis.backendapi.BackendapiApplication",
"projectName": "backendapi",
"args": "",
"envFile": "${workspaceFolder}/.env"
}
]
}

53
.vscode/settings.json vendored
View file

@ -1,34 +1,23 @@
{
"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"
}
}
"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"
}

View file

@ -1,36 +0,0 @@
# 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
View file

@ -1,293 +1 @@
# 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
View file

@ -1,98 +0,0 @@
# 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`

View file

@ -1,11 +0,0 @@
<?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>

View file

@ -1,450 +0,0 @@
<?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*&quot;[^&quot;]*&quot;|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>

View file

@ -1,47 +0,0 @@
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
View file

@ -1,296 +1,77 @@
<?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>
<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>
<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>
<modules>
<module>src/common</module>
<module>src/domain</module>
<module>src/infrastructure</module>
<module>src/application</module>
<module>src/api/v1</module>
</modules>
<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>
<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>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View file

@ -1,46 +0,0 @@
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

View file

@ -1,24 +0,0 @@
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

View file

@ -1,40 +0,0 @@
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

View file

@ -1,31 +0,0 @@
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

View file

@ -1,31 +0,0 @@
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

View file

@ -1,25 +0,0 @@
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

View file

@ -1,25 +0,0 @@
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

View file

@ -1,28 +0,0 @@
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

View file

@ -1,7 +0,0 @@
info:
name: Employee
type: folder
seq: 1
request:
auth: inherit

View file

@ -1,7 +0,0 @@
info:
name: ApiV1
type: folder
seq: 1
request:
auth: inherit

View file

@ -1,15 +1,11 @@
info:
name: Get Employee
name: Root
type: http
seq: 7
seq: 1
http:
method: GET
url: "{{ENDPOINT}}/employee/:employeeId"
params:
- name: employeeId
value: "1"
type: path
url: "{{HOST}}:{{PORT}}/"
auth: inherit
settings:

View file

@ -4,7 +4,3 @@ variables:
value: 127.0.0.1
- name: PORT
value: "8080"
- name: VERSION
value: v1
- name: ENDPOINT
value: "http://{{HOST}}:{{PORT}}/api/{{VERSION}}"

View file

@ -13,21 +13,6 @@ 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:

View file

@ -1,123 +0,0 @@
<?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>

View file

@ -1,27 +0,0 @@
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);
}
}

View file

@ -1,39 +0,0 @@
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.");
}
}

View file

@ -1,237 +0,0 @@
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;
}
}
}
}

View file

@ -1,2 +0,0 @@
@org.jspecify.annotations.NullMarked
package com.ldc.arcadiagenesis.backendapi.application.apiv1.components;

View file

@ -1,16 +0,0 @@
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);
}
}

View file

@ -1,52 +0,0 @@
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);
}
}

View file

@ -1,6 +0,0 @@
package com.ldc.arcadiagenesis.backendapi.application.apiv1.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "api.signature")
public record SignatureProperties(boolean enabled) {}

View file

@ -1,6 +0,0 @@
package com.ldc.arcadiagenesis.backendapi.application.apiv1.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "spring.profiles")
public record SpringProfiles(String active) {}

View file

@ -1,56 +0,0 @@
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();
}
}

View file

@ -1,14 +0,0 @@
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();
}
}

View file

@ -1,2 +0,0 @@
@org.jspecify.annotations.NullMarked
package com.ldc.arcadiagenesis.backendapi.application.apiv1.config;

View file

@ -1,5 +0,0 @@
package com.ldc.arcadiagenesis.backendapi.application.apiv1.dto;
import java.time.ZonedDateTime;
public record ApiErrorDetails(String message, ZonedDateTime timestamp) {}

View file

@ -1,2 +0,0 @@
@org.jspecify.annotations.NullMarked
package com.ldc.arcadiagenesis.backendapi.application.apiv1.dto;

View file

@ -1,48 +0,0 @@
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) {}
}

View file

@ -1,16 +0,0 @@
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) {}

View file

@ -1,16 +0,0 @@
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) {}

View file

@ -1,41 +0,0 @@
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) {}

View file

@ -1,10 +0,0 @@
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) {}

View file

@ -1,2 +0,0 @@
@org.jspecify.annotations.NullMarked
package com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.request;

View file

@ -1,12 +0,0 @@
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) {}

View file

@ -1,2 +0,0 @@
@org.jspecify.annotations.NullMarked
package com.ldc.arcadiagenesis.backendapi.application.apiv1.dto.response;

View file

@ -1,35 +0,0 @@
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");
}
}

View file

@ -1,37 +0,0 @@
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");
}
}

View file

@ -1,35 +0,0 @@
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");
}
}

View file

@ -1,34 +0,0 @@
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");
}
}

View file

@ -1,156 +0,0 @@
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);
}
}

View file

@ -1,11 +0,0 @@
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);
}
}

View file

@ -1,11 +0,0 @@
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);
}
}

View file

@ -1,11 +0,0 @@
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);
}
}

View file

@ -1,11 +0,0 @@
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);
}
}

View file

@ -1,15 +0,0 @@
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);
}
}

View file

@ -1,11 +0,0 @@
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);
}
}

View file

@ -1,15 +0,0 @@
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);
}
}

View file

@ -1,15 +0,0 @@
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);
}
}

View file

@ -1,115 +0,0 @@
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();
}
}

View file

@ -1,32 +0,0 @@
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));
};
}
}

View file

@ -1,87 +0,0 @@
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);
}
}

View file

@ -1,50 +0,0 @@
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());
}
}

View file

@ -1,2 +0,0 @@
@org.jspecify.annotations.NullMarked
package com.ldc.arcadiagenesis.backendapi.application.apiv1.mappers;

View file

@ -1,2 +0,0 @@
@org.jspecify.annotations.NullMarked
package com.ldc.arcadiagenesis.backendapi.application.apiv1;

View file

@ -1,200 +0,0 @@
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);
}
}
}

View file

@ -1,150 +0,0 @@
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);
}
}
}

View file

@ -1,2 +0,0 @@
@org.jspecify.annotations.NullMarked
package com.ldc.arcadiagenesis.backendapi.application.apiv1.routes.arcadiagenesis.employee;

View file

@ -1,2 +0,0 @@
@org.jspecify.annotations.NullMarked
package com.ldc.arcadiagenesis.backendapi.application.apiv1.routes;

View file

@ -1,123 +0,0 @@
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);
}
}
}

View file

@ -1,5 +0,0 @@
package com.ldc.arcadiagenesis.backendapi.application.apiv1.seed;
public interface Seeder {
void seed();
}

View file

@ -1,2 +0,0 @@
@org.jspecify.annotations.NullMarked
package com.ldc.arcadiagenesis.backendapi.application.apiv1.seed;

View file

@ -1,163 +0,0 @@
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) {}
}

View file

@ -1,25 +0,0 @@
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) {}
}

View file

@ -1,78 +0,0 @@
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) {}
}

View file

@ -1,13 +0,0 @@
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";
}
}

View file

@ -1,9 +0,0 @@
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);
}

View file

@ -1,30 +0,0 @@
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));
}
}
}

View file

@ -1,7 +0,0 @@
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);
}

View file

@ -1,13 +0,0 @@
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";
}
}

View file

@ -1,30 +0,0 @@
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);
}
}

View file

@ -1,27 +0,0 @@
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);
}
}

View file

@ -1,2 +0,0 @@
@org.jspecify.annotations.NullMarked
package com.ldc.arcadiagenesis.backendapi.application.apiv1.services;

View file

@ -1,14 +0,0 @@
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();
}
}

View file

@ -1,21 +0,0 @@
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 {};
}

View file

@ -1,29 +0,0 @@
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);
}
}

View file

@ -1,21 +0,0 @@
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 {};
}

View file

@ -1,41 +0,0 @@
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
}
}

View file

@ -1,21 +0,0 @@
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 {};
}

View file

@ -1,30 +0,0 @@
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
}
}

View file

@ -1,21 +0,0 @@
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 {};
}

View file

@ -1,68 +0,0 @@
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
}
}

View file

@ -1,2 +0,0 @@
@org.jspecify.annotations.NullMarked
package com.ldc.arcadiagenesis.backendapi.application.apiv1.validation;

View file

@ -1,73 +0,0 @@
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;
}
}

View file

@ -1,91 +0,0 @@
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