diff --git a/.claude/agents/spring-boot-engineer.md b/.claude/agents/spring-boot-engineer.md new file mode 100644 index 0000000..3242283 --- /dev/null +++ b/.claude/agents/spring-boot-engineer.md @@ -0,0 +1,286 @@ +--- +name: spring-boot-engineer +description: Expert Spring Boot engineer mastering Spring Boot 3+ with cloud-native patterns. Specializes in microservices, reactive programming, Spring Cloud integration, and enterprise solutions with focus on building scalable, production-ready applications. +tools: Read, Write, Edit, Bash, Glob, Grep +--- + +You are a senior Spring Boot engineer with expertise in Spring Boot 3+ and cloud-native Java development. Your focus spans microservices architecture, reactive programming, Spring Cloud ecosystem, and enterprise integration with emphasis on creating robust, scalable applications that excel in production environments. + + +When invoked: +1. Query context manager for Spring Boot project requirements and architecture +2. Review application structure, integration needs, and performance requirements +3. Analyze microservices design, cloud deployment, and enterprise patterns +4. Implement Spring Boot solutions with scalability and reliability focus + +Spring Boot engineer checklist: +- Spring Boot 3.x features utilized properly +- Java 21+ features leveraged effectively +- GraalVM native support configured correctly +- Test coverage > 85% achieved consistently +- API documentation complete thoroughly +- Security hardened implemented properly +- Cloud-native ready verified completely +- Performance optimized maintained successfully + +Spring Boot features: +- Auto-configuration +- Starter dependencies +- Actuator endpoints +- Configuration properties +- Profiles management +- DevTools usage +- Native compilation +- Virtual threads + +Microservices patterns: +- Service discovery +- Config server +- API gateway +- Circuit breakers +- Distributed tracing +- Event sourcing +- Saga patterns +- Service mesh + +Reactive programming: +- WebFlux patterns +- Reactive streams +- Mono/Flux usage +- Backpressure handling +- Non-blocking I/O +- R2DBC database +- Reactive security +- Testing reactive + +Spring Cloud: +- Netflix OSS +- Spring Cloud Gateway +- Config management +- Service discovery +- Circuit breaker +- Distributed tracing +- Stream processing +- Contract testing + +Data access: +- Spring Data JPA +- Query optimization +- Transaction management +- Multi-datasource +- Database migrations +- Caching strategies +- NoSQL integration +- Reactive data + +Security implementation: +- Spring Security +- OAuth2/JWT +- Method security +- CORS configuration +- CSRF protection +- Rate limiting +- API key management +- Security headers + +Enterprise integration: +- Message queues +- Kafka integration +- REST clients +- SOAP services +- Batch processing +- Scheduling tasks +- Event handling +- Integration patterns + +Testing strategies: +- Unit testing +- Integration tests +- MockMvc usage +- WebTestClient +- Testcontainers +- Contract testing +- Load testing +- Security testing + +Performance optimization: +- JVM tuning +- Connection pooling +- Caching layers +- Async processing +- Database optimization +- Native compilation +- Memory management +- Monitoring setup + +Cloud deployment: +- Docker optimization +- Kubernetes ready +- Health checks +- Graceful shutdown +- Configuration management +- Service mesh +- Observability +- Auto-scaling + +## Communication Protocol + +### Spring Boot Context Assessment + +Initialize Spring Boot development by understanding enterprise requirements. + +Spring Boot context query: +```json +{ + "requesting_agent": "spring-boot-engineer", + "request_type": "get_spring_context", + "payload": { + "query": "Spring Boot context needed: application type, microservices architecture, integration requirements, performance goals, and deployment environment." + } +} +``` + +## Development Workflow + +Execute Spring Boot development through systematic phases: + +### 1. Architecture Planning + +Design enterprise Spring Boot architecture. + +Planning priorities: +- Service design +- API structure +- Data architecture +- Integration points +- Security strategy +- Testing approach +- Deployment pipeline +- Monitoring plan + +Architecture design: +- Define services +- Plan APIs +- Design data model +- Map integrations +- Set security rules +- Configure testing +- Setup CI/CD +- Document architecture + +### 2. Implementation Phase + +Build robust Spring Boot applications. + +Implementation approach: +- Create services +- Implement APIs +- Setup data access +- Add security +- Configure cloud +- Write tests +- Optimize performance +- Deploy services + +Spring patterns: +- Dependency injection +- AOP aspects +- Event-driven +- Configuration management +- Error handling +- Transaction management +- Caching strategies +- Monitoring integration + +Progress tracking: +```json +{ + "agent": "spring-boot-engineer", + "status": "implementing", + "progress": { + "services_created": 8, + "apis_implemented": 42, + "test_coverage": "88%", + "startup_time": "2.3s" + } +} +``` + +### 3. Spring Boot Excellence + +Deliver exceptional Spring Boot applications. + +Excellence checklist: +- Architecture scalable +- APIs documented +- Tests comprehensive +- Security robust +- Performance optimized +- Cloud-ready +- Monitoring active +- Documentation complete + +Delivery notification: +"Spring Boot application completed. Built 8 microservices with 42 APIs achieving 88% test coverage. Implemented reactive architecture with 2.3s startup time. GraalVM native compilation reduces memory by 75%." + +Microservices excellence: +- Service autonomous +- APIs versioned +- Data isolated +- Communication async +- Failures handled +- Monitoring complete +- Deployment automated +- Scaling configured + +Reactive excellence: +- Non-blocking throughout +- Backpressure handled +- Error recovery robust +- Performance optimal +- Resource efficient +- Testing complete +- Debugging tools +- Documentation clear + +Security excellence: +- Authentication solid +- Authorization granular +- Encryption enabled +- Vulnerabilities scanned +- Compliance met +- Audit logging +- Secrets managed +- Headers configured + +Performance excellence: +- Startup fast +- Memory efficient +- Response times low +- Throughput high +- Database optimized +- Caching effective +- Native ready +- Metrics tracked + +Best practices: +- 12-factor app +- Clean architecture +- SOLID principles +- DRY code +- Test pyramid +- API first +- Documentation current +- Code reviews thorough + +Integration with other agents: +- Collaborate with java-architect on Java patterns +- Support microservices-architect on architecture +- Work with database-optimizer on data access +- Guide devops-engineer on deployment +- Help security-auditor on security +- Assist performance-engineer on optimization +- Partner with api-designer on API design +- Coordinate with cloud-architect on cloud deployment + +Always prioritize reliability, scalability, and maintainability while building Spring Boot applications that handle enterprise workloads with excellence. diff --git a/.github/scripts/update-spring-versions.py b/.github/scripts/update-spring-versions.py deleted file mode 100644 index 1578b97..0000000 --- a/.github/scripts/update-spring-versions.py +++ /dev/null @@ -1,67 +0,0 @@ -import json -import re -from collections import defaultdict -from packaging.version import Version -from xml.etree import ElementTree as ET -import urllib.request - -# Configuration -managed_minors = {"3.4"} -boot_java_compatibility = { - "3.4": ["17", "21"] -} -output_path = ".github/spring-versions.json" - -# Step 1: Fetch Spring Boot versions -metadata_url = "https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-starter/maven-metadata.xml" -with urllib.request.urlopen(metadata_url) as response: - xml_data = response.read() - -root = ET.fromstring(xml_data) -versions = [v.text for v in root.findall(".//version")] - -boot_versions = defaultdict(list) -for v in versions: - if not re.match(r"^\d+\.\d+\.\d+$", v): - continue - minor = ".".join(v.split(".")[:2]) - if minor in managed_minors: - boot_versions[minor].append(Version(v)) - -latest_boot_versions = {minor: str(max(vlist)) for minor, vlist in boot_versions.items()} - -# Step 2: Resolve Spring Framework version -def get_spring_framework_version(boot_version: str) -> str: - pom_url = f"https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-starter/{boot_version}/spring-boot-starter-{boot_version}.pom" - try: - with urllib.request.urlopen(pom_url) as response: - pom_data = response.read() - pom_root = ET.fromstring(pom_data) - ns = {'m': 'http://maven.apache.org/POM/4.0.0'} - for dep in pom_root.findall(".//m:dependency", ns): - gid = dep.find("m:groupId", ns) - aid = dep.find("m:artifactId", ns) - ver = dep.find("m:version", ns) - if gid is not None and aid is not None and ver is not None: - if gid.text == "org.springframework" and aid.text == "spring-core": - return ver.text - except Exception as e: - print(f"Warning: Failed to fetch framework version for {boot_version}: {e}") - return "unknown" - -# Step 3: Build matrix -matrix_entries = [] -for minor, boot_version in sorted(latest_boot_versions.items()): - framework_version = get_spring_framework_version(boot_version) - for java_version in boot_java_compatibility.get(minor, []): - matrix_entries.append({ - "boot": boot_version, - "framework": framework_version, - "java": str(java_version) - }) - -# Step 4: Save result -with open(output_path, "w") as f: - json.dump({"matrix": matrix_entries}, f, indent=2) - -print(f"Updated {output_path} with {len(matrix_entries)} matrix entries.") diff --git a/.github/spring-versions.json b/.github/spring-versions.json deleted file mode 100644 index cc14e9b..0000000 --- a/.github/spring-versions.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "matrix": [ - { - "boot": "3.4.13", - "framework": "6.2.15", - "java": "17" - }, - { - "boot": "3.4.13", - "framework": "6.2.15", - "java": "21" - } - ] -} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2fe12c4..b98af00 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,57 +8,18 @@ on: branches: [ "master" ] workflow_dispatch: -jobs: - generate-matrix: - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} - name: Generate version matrix - steps: - - uses: actions/checkout@v6 - - name: Read matrix from JSON - id: set-matrix - run: | - MATRIX=$(jq -c '.matrix' .github/spring-versions.json) - echo "matrix={\"include\":$MATRIX}" >> $GITHUB_OUTPUT - - regression-tests: - needs: generate-matrix - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} - name: Test Spring Boot ${{ matrix.boot }} / Java ${{ matrix.java }} - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Setup Java - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: ${{ matrix.java }} +env: + DEFAULT_JAVA_VERSION: '17' - - name: Run tests and generate reports - run: ./gradlew testAndReport -PspringBootVersion=${{ matrix.boot }} -PspringFrameworkVersion=${{ matrix.framework }} - - - name: Upload Artifact - uses: actions/upload-artifact@v7 - if: always() - with: - name: report-java-${{ matrix.java }}-spring-boot-${{ matrix.boot }} - path: build/reports/** - retention-days: 5 +jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - java: [ '17', '21' ] - name: Test Spring Boot latest / Java ${{ matrix.java }} + java: [ '17', '21', '25' ] + name: Test with Java ${{ matrix.java }} steps: - name: Checkout code uses: actions/checkout@v6 @@ -78,13 +39,13 @@ jobs: uses: actions/upload-artifact@v7 if: always() with: - name: report-java-${{ matrix.java }}-spring-boot-latest + name: report-java-${{ matrix.java }} path: build/reports/** retention-days: 5 - name: Run Sonar analysis # Skip Sonar on Dependabot in pull_request runs (no secrets there); handled by a separate job below - if: matrix.java == '17' && github.actor != 'dependabot[bot]' + if: matrix.java == env.DEFAULT_JAVA_VERSION && github.actor != 'dependabot[bot]' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} @@ -94,7 +55,7 @@ jobs: sonar-dependabot: name: Sonar (Dependabot PRs) # Only run when the event is pull_request_target and the actor is Dependabot - if: github.event_name == 'pull_request_target' && github.actor == 'dependabot[bot]' + if: github.event_name == 'pull_request_target' && github.event.pull_request.user.login == 'dependabot[bot]' runs-on: ubuntu-latest permissions: contents: read @@ -111,7 +72,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: temurin - java-version: '17' + java-version: ${{ env.DEFAULT_JAVA_VERSION }} - name: Build (no tests) run: ./gradlew assemble -x test @@ -133,7 +94,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: temurin - java-version: 17 + java-version: ${{ env.DEFAULT_JAVA_VERSION }} - name: Run build with Gradle Wrapper run: ./gradlew build -x test diff --git a/.github/workflows/update-spring-versions.yml b/.github/workflows/update-spring-versions.yml deleted file mode 100644 index 8d152fe..0000000 --- a/.github/workflows/update-spring-versions.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Update Spring Versions Matrix - -on: - schedule: - - cron: '0 3 1 * *' # Monthly on the 1st at 03:00 UTC - workflow_dispatch: # Allow manual trigger as well - -jobs: - update-matrix: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.x' - - - name: Install Python dependencies - run: pip install packaging - - - name: Run update script - run: python .github/scripts/update-spring-versions.py - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v8 - with: - commit-message: "chore: update Spring Boot/Framework testing matrix" - title: "Update Spring Versions Matrix" - body: | - This PR updates the `spring-versions.json` matrix with the latest patch versions of Spring Boot and their corresponding Spring Framework versions for Java 17 and 21. - branch: update/spring-versions-matrix - labels: | - dependencies - spring - author: github-actions[bot] diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 0000000..da9308a --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1 @@ +java=17.0.15-tem diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b7d3472 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development notes +* Always use Context7 MCP when I need library/API documentation, code generation, setup or configuration steps without me having to explicitly ask. +* Always add modified and new files to Git, but do not commit them. +* Use Lombok whenever possible +* Always remember to use specialized agents to write or fix code and related tests: + * spring-boot-engineer for coding anything (including unit and integration tests) + +## Project Overview + +Bitweb Spring Core library (`ee.bitweb:spring-core`) - a reusable library providing generic functionality for Spring Boot HTTP web services. Published to Maven Central. + +## Build Commands + +```bash +# Build the project +./gradlew build + +# Run all tests +./gradlew test + +# Run only unit tests (excludes @Tag("integration")) +./gradlew unitTest + +# Run only integration tests (includes @Tag("integration")) +./gradlew integrationTest + +# Run a single test class +./gradlew test --tests "ee.bitweb.core.api.model.exception.ControllerAdvisorIntegrationTests" + +# Run tests with coverage reports +./gradlew testAndReport +``` + +## Architecture + +This is a Spring Boot auto-configuration library. Features are enabled via property flags with the pattern `ee.bitweb.core..auto-configuration=true`. + +### Core Modules + +- **trace** - Request tracing with trace ID propagation across HTTP requests, AMQP messages, and threads. Uses MDC for logging context. +- **api** - Global exception handling via `ControllerAdvisor` with standardized error responses. +- **audit** - HTTP request/response audit logging with pluggable mappers and writers. +- **retrofit** - Retrofit HTTP client integration with `SpringAwareRetrofitBuilder` for building API clients with automatic interceptors and configuration. +- **amqp** - RabbitMQ integration with automatic trace ID propagation and message converters. +- **actuator** - Spring Actuator security configuration. +- **cors** - CORS auto-configuration. +- **validator** - Custom Jakarta validators (`@FileType`, `@Uppercase`). + +### Key Patterns + +- Auto-configuration classes use `@ConditionalOnProperty` with prefix `ee.bitweb.core.` +- Most beans are `@ConditionalOnMissingBean` allowing override by consuming applications +- Properties classes follow pattern `Properties.java` with `PREFIX` constant +- Integration tests use `@Tag("integration")` annotation + +## Testing + +- Uses JUnit 5 with Spring Boot Test +- Integration tests require `@Tag("integration")` annotation +- Test application: `ee.bitweb.core.TestSpringApplication` +- Uses Testcontainers for RabbitMQ integration tests +- Uses MockServer for HTTP client tests + +## Java Version + +Java 17 (Temurin distribution via SDKMAN) + +## Spring Boot Version + +Spring Boot 4.0.0 with Spring Framework 7.0.0 + +Note: This version uses Jackson 3.x (tools.jackson package) for exception handling in the ControllerAdvisor. diff --git a/build.gradle b/build.gradle index 79cc1f5..3c54e02 100644 --- a/build.gradle +++ b/build.gradle @@ -1,25 +1,11 @@ -buildscript { - ext { - springBootVersion = project.hasProperty('springBootVersion') ? project.springBootVersion : '3.5.0' - springVersion = project.hasProperty('springFrameworkVersion') ? project.springFrameworkVersion : '6.2.7' - jacksonVersion = '2.21.1' - retrofitVersion = '3.0.0' - mockitoVersion = '5.22.+' // At least Mockito 5.2.0 is required to Mock final classes - } - dependencies { - classpath 'org.owasp:dependency-check-gradle' - } -} - plugins { id 'java' - id "org.owasp.dependencycheck" version "12.2.0" id "io.freefair.lombok" version "9.2.0" id "io.github.gradle-nexus.publish-plugin" version "2.0.0" } -group 'ee.bitweb' -version '4.0.5' +group = 'ee.bitweb' +version = '5.0.0-RC1' java { sourceCompatibility = '17' } @@ -38,82 +24,95 @@ repositories { dependencies { // https://mvnrepository.com/artifact/org.apache.tomcat/tomcat-catalina - compileOnly group: 'org.apache.tomcat', name: 'tomcat-catalina', version: '10.1.52' + compileOnly 'org.apache.tomcat:tomcat-catalina:11.0.12' // https://mvnrepository.com/artifact/org.springframework/spring-webmvc - compileOnly group: 'org.springframework', name: 'spring-webmvc', version: "${springVersion}" + compileOnly 'org.springframework:spring-webmvc:7.0.0' // https://mvnrepository.com/artifact/org.springframework/spring-tx - compileOnly group: 'org.springframework', name: 'spring-tx', version: "${springVersion}" + compileOnly 'org.springframework:spring-tx:7.0.0' // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security - compileOnly group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: "${springBootVersion}" + compileOnly 'org.springframework.boot:spring-boot-starter-security:4.0.0' // https://mvnrepository.com/artifact/org.springframework.amqp/spring-amqp - compileOnly group: 'org.springframework.boot', name: 'spring-boot-starter-amqp', version: "${springBootVersion}" + compileOnly 'org.springframework.boot:spring-boot-starter-amqp:4.0.0' // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-configuration-processor - annotationProcessor group: 'org.springframework.boot', name: 'spring-boot-configuration-processor', version: "${springBootVersion}" + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor:4.0.0' // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-actuator - compileOnly group: 'org.springframework.boot', name: 'spring-boot-starter-actuator', version: "${springBootVersion}" + compileOnly 'org.springframework.boot:spring-boot-starter-actuator:4.0.0' + + // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-health (new in Spring Boot 4) + compileOnly 'org.springframework.boot:spring-boot-health:4.0.0' // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-autoconfigure - compileOnly group: 'org.springframework.boot', name: 'spring-boot-autoconfigure', version: "${springBootVersion}" + compileOnly 'org.springframework.boot:spring-boot-autoconfigure:4.0.0' // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-validation - compileOnly group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: "${springBootVersion}" + compileOnly 'org.springframework.boot:spring-boot-starter-validation:4.0.0' + + // Jackson 2.x - required for Retrofit converter-jackson (Retrofit doesn't support Jackson 3 yet) + compileOnly 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1' - // https://mvnrepository.com/artifact/com.fasterxml.jackson.datatype/jackson-datatype-jsr310 - compileOnly group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version: "${jacksonVersion}" + // Jackson 3.x - required for Spring Boot 4 (ObjectMapper, ControllerAdvisor, Audit module) + compileOnly 'tools.jackson.core:jackson-databind:3.0.2' + + // Spring Boot Jackson - provides JsonMapperBuilderCustomizer + compileOnly 'org.springframework.boot:spring-boot-jackson:4.0.0' // https://mvnrepository.com/artifact/jakarta.validation/jakarta.validation-api - compileOnly group: 'jakarta.validation', name: 'jakarta.validation-api', version: '3.1.1' + compileOnly 'jakarta.validation:jakarta.validation-api:3.1.1' // https://mvnrepository.com/artifact/com.squareup.retrofit2/retrofit - compileOnly group: 'com.squareup.retrofit2', name: 'retrofit', version: "${retrofitVersion}" + compileOnly 'com.squareup.retrofit2:retrofit:3.0.0' // https://mvnrepository.com/artifact/com.squareup.retrofit2/converter-jackson - compileOnly group: 'com.squareup.retrofit2', name: 'converter-jackson', version: "${retrofitVersion}" + compileOnly 'com.squareup.retrofit2:converter-jackson:3.0.0' // https://mvnrepository.com/artifact/com.squareup.okhttp3/logging-interceptor - compileOnly group: 'com.squareup.okhttp3', name: 'logging-interceptor', version: '4.12.0' + compileOnly 'com.squareup.okhttp3:logging-interceptor:5.3.2' // https://mvnrepository.com/artifact/de.siegmar/logback-gelf - compileOnly group: 'de.siegmar', name: 'logback-gelf', version: '6.1.+' + compileOnly 'de.siegmar:logback-gelf:6.1.2' // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-web - testImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: "${springBootVersion}" + testImplementation 'org.springframework.boot:spring-boot-starter-web:4.0.0' // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-test - testImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-test', version: "${springBootVersion}" + testImplementation 'org.springframework.boot:spring-boot-starter-test:4.0.0' + + // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-webmvc-test (new in Spring Boot 4 for MockMvc testing) + testImplementation 'org.springframework.boot:spring-boot-webmvc-test:4.0.0' // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security - testImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: "${springBootVersion}" + testImplementation 'org.springframework.boot:spring-boot-starter-security:4.0.0' // https://mvnrepository.com/artifact/org.springframework.amqp/spring-amqp - testImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-amqp', version: "${springBootVersion}" + testImplementation 'org.springframework.boot:spring-boot-starter-amqp:4.0.0' // https://mvnrepository.com/artifact/org.json/json - testImplementation group: 'org.json', name: 'json', version: '20250517' + testImplementation 'org.json:json:20251224' // https://mvnrepository.com/artifact/ee.bitweb/spring-test-core - testImplementation group: 'ee.bitweb', name: 'spring-test-core', version: '2.+' + testImplementation 'ee.bitweb:spring-test-core:2.0.1' // https://mvnrepository.com/artifact/org.mockito/mockito-core - testImplementation group: 'org.mockito', name: 'mockito-core', version: "${mockitoVersion}" + testImplementation 'org.mockito:mockito-core:5.21.0' // https://mvnrepository.com/artifact/org.mockito/mockito-junit-jupiter - testImplementation group: 'org.mockito', name: 'mockito-junit-jupiter', version: "${mockitoVersion}" + testImplementation 'org.mockito:mockito-junit-jupiter:5.21.0' // https://mvnrepository.com/artifact/org.mock-server/mockserver-netty - testImplementation ("org.mock-server:mockserver-netty:5.15.0") { + testImplementation('org.mock-server:mockserver-netty:5.15.0') { exclude group: 'junit', module: 'junit' } - testImplementation group : 'org.testcontainers', name: 'testcontainers', version: "1.+" + // https://mvnrepository.com/artifact/org.testcontainers/testcontainers + testImplementation 'org.testcontainers:testcontainers:2.0.3' - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } configurations { diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55..f8e1ee3 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 002b867..23449a2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 23d15a9..adff685 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index db3a6ac..c4bdd3a 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/library.gradle b/library.gradle index ad3bc43..0c093d1 100644 --- a/library.gradle +++ b/library.gradle @@ -82,8 +82,8 @@ nexusPublishing { signing { if (project.hasProperty("signingKey") & project.hasProperty("signingPassword")) { useInMemoryPgpKeys(project.signingKey, project.signingPassword) + sign publishing.publications.mavenJava } - sign publishing.publications.mavenJava } javadoc { @@ -94,4 +94,5 @@ javadoc { jar { from sourceSets.main.allSource + manifest.attributes(Map.of("Automatic-Module-Name", "ee.bitweb.core")) } diff --git a/sonarcloud.gradle b/sonarcloud.gradle index 4899bef..52fb41b 100644 --- a/sonarcloud.gradle +++ b/sonarcloud.gradle @@ -6,7 +6,7 @@ buildscript { } } dependencies { - classpath("org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:6.2.0.5505") + classpath("org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:7.2.2.6593") } } apply plugin: org.sonarqube.gradle.SonarQubePlugin diff --git a/src/main/java/ee/bitweb/core/actuator/ActuatorHealthSecurity.java b/src/main/java/ee/bitweb/core/actuator/ActuatorHealthSecurity.java index 3bb6db5..6486748 100644 --- a/src/main/java/ee/bitweb/core/actuator/ActuatorHealthSecurity.java +++ b/src/main/java/ee/bitweb/core/actuator/ActuatorHealthSecurity.java @@ -2,8 +2,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointProperties; -import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; +import org.springframework.boot.health.autoconfigure.actuate.endpoint.HealthEndpointProperties; +import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest; import org.springframework.boot.actuate.endpoint.Show; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Configuration; @@ -25,7 +25,7 @@ public class ActuatorHealthSecurity { private final HealthEndpointProperties healthEndpointProperties; - protected void configure(HttpSecurity httpSecurity) throws Exception { + protected void configure(HttpSecurity httpSecurity) { List allowedRoles = actuatorSecurityProperties.getHealthEndpointRoles(); logUnsafeHealthEndpointWarning(); diff --git a/src/main/java/ee/bitweb/core/actuator/ActuatorSecurity.java b/src/main/java/ee/bitweb/core/actuator/ActuatorSecurity.java index fc02eaa..9f50d09 100644 --- a/src/main/java/ee/bitweb/core/actuator/ActuatorSecurity.java +++ b/src/main/java/ee/bitweb/core/actuator/ActuatorSecurity.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; +import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/ee/bitweb/core/amqp/AmqpAutoConfiguration.java b/src/main/java/ee/bitweb/core/amqp/AmqpAutoConfiguration.java index 0dcee80..0975cea 100644 --- a/src/main/java/ee/bitweb/core/amqp/AmqpAutoConfiguration.java +++ b/src/main/java/ee/bitweb/core/amqp/AmqpAutoConfiguration.java @@ -1,6 +1,6 @@ package ee.bitweb.core.amqp; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import ee.bitweb.core.trace.invoker.amqp.AmqpTraceAdvisor; import lombok.extern.slf4j.Slf4j; import org.aopalliance.intercept.MethodInterceptor; @@ -9,9 +9,9 @@ import org.springframework.amqp.rabbit.connection.ConnectionFactory; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.amqp.rabbit.listener.ConditionalRejectingErrorHandler; -import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.JacksonJsonMessageConverter; import org.springframework.amqp.support.converter.MessageConverter; -import org.springframework.boot.autoconfigure.amqp.SimpleRabbitListenerContainerFactoryConfigurer; +import org.springframework.boot.amqp.autoconfigure.SimpleRabbitListenerContainerFactoryConfigurer; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -105,7 +105,7 @@ public RabbitTemplate rabbitTemplate( @Bean @ConditionalOnMissingBean - public MessageConverter jsonMessageConverter(ObjectMapper mapper) { - return new Jackson2JsonMessageConverter(mapper); + public MessageConverter jsonMessageConverter(JsonMapper mapper) { + return new JacksonJsonMessageConverter(mapper); } } diff --git a/src/main/java/ee/bitweb/core/api/ControllerAdvisor.java b/src/main/java/ee/bitweb/core/api/ControllerAdvisor.java index ea3e7e5..8179d59 100644 --- a/src/main/java/ee/bitweb/core/api/ControllerAdvisor.java +++ b/src/main/java/ee/bitweb/core/api/ControllerAdvisor.java @@ -15,8 +15,8 @@ import ee.bitweb.core.api.model.exception.ValidationErrorResponse; import ee.bitweb.core.exception.validation.InvalidFormatValidationException; -import com.fasterxml.jackson.databind.exc.InvalidFormatException; -import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import tools.jackson.databind.exc.InvalidFormatException; +import tools.jackson.databind.exc.MismatchedInputException; import ee.bitweb.core.retrofit.RetrofitException; import ee.bitweb.core.trace.context.TraceIdContext; import lombok.RequiredArgsConstructor; @@ -343,6 +343,7 @@ private void log(ControllerAdvisorProperties.Level level, String message, Throwa case DEBUG -> log.debug(message); case TRACE -> log.trace(message); case OFF -> { + // nothing to do, logging has been turned off } } } diff --git a/src/main/java/ee/bitweb/core/api/ControllerAdvisorProperties.java b/src/main/java/ee/bitweb/core/api/ControllerAdvisorProperties.java index 78cbc9e..19996bd 100644 --- a/src/main/java/ee/bitweb/core/api/ControllerAdvisorProperties.java +++ b/src/main/java/ee/bitweb/core/api/ControllerAdvisorProperties.java @@ -64,17 +64,10 @@ public static class Logging { private Level multipartException = Level.WARN; @NotNull - @Deprecated(since = "3.1.0", forRemoval = true) - /** - * @deprecated As of 3.1.0 prefer entityNotFoundException and conflictException properties over given property. - */ - private Level persistenceException = Level.ERROR; + private Level entityNotFoundException = Level.ERROR; @NotNull - private Level entityNotFoundException = persistenceException; - - @NotNull - private Level conflictException = persistenceException; + private Level conflictException = Level.ERROR; @NotNull private Level retrofitException = Level.INFO; diff --git a/src/main/java/ee/bitweb/core/api/model/exception/CriteriaResponse.java b/src/main/java/ee/bitweb/core/api/model/exception/CriteriaResponse.java index 5de3c20..be0c57e 100644 --- a/src/main/java/ee/bitweb/core/api/model/exception/CriteriaResponse.java +++ b/src/main/java/ee/bitweb/core/api/model/exception/CriteriaResponse.java @@ -7,6 +7,7 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; @Getter @EqualsAndHashCode @@ -27,7 +28,7 @@ public CriteriaResponse(Criteria criteria) { } @Override - public int compareTo(CriteriaResponse o) { + public int compareTo(@NotNull CriteriaResponse o) { return Comparator.nullsFirst( Comparator.comparing(CriteriaResponse::getField, NULL_SAFE_STRING_COMPARATOR) .thenComparing(CriteriaResponse::getValue, NULL_SAFE_STRING_COMPARATOR) diff --git a/src/main/java/ee/bitweb/core/api/model/exception/FieldErrorResponse.java b/src/main/java/ee/bitweb/core/api/model/exception/FieldErrorResponse.java index 9a47fa5..ff366c3 100644 --- a/src/main/java/ee/bitweb/core/api/model/exception/FieldErrorResponse.java +++ b/src/main/java/ee/bitweb/core/api/model/exception/FieldErrorResponse.java @@ -8,6 +8,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.ToString; +import org.jetbrains.annotations.NotNull; @ToString @Getter @@ -30,7 +31,7 @@ public FieldErrorResponse(FieldError e) { } @Override - public int compareTo(FieldErrorResponse o) { + public int compareTo(@NotNull FieldErrorResponse o) { return Comparator.nullsFirst( Comparator.comparing(FieldErrorResponse::getField, NULL_SAFE_STRING_COMPARATOR) .thenComparing(FieldErrorResponse::getReason, NULL_SAFE_STRING_COMPARATOR) diff --git a/src/main/java/ee/bitweb/core/api/model/exception/PersistenceErrorResponse.java b/src/main/java/ee/bitweb/core/api/model/exception/PersistenceErrorResponse.java index cc10a35..9866bab 100644 --- a/src/main/java/ee/bitweb/core/api/model/exception/PersistenceErrorResponse.java +++ b/src/main/java/ee/bitweb/core/api/model/exception/PersistenceErrorResponse.java @@ -2,7 +2,6 @@ import java.util.Set; import java.util.TreeSet; -import java.util.stream.Collectors; import ee.bitweb.core.exception.persistence.Criteria; import ee.bitweb.core.exception.persistence.PersistenceException; @@ -21,7 +20,7 @@ public class PersistenceErrorResponse extends GenericErrorResponse { public PersistenceErrorResponse(String id, String message, String entity, Set criteria) { super(id, message); this.entity = entity; - this.criteria.addAll(criteria.stream().map(CriteriaResponse::new).collect(Collectors.toList())); + this.criteria.addAll(criteria.stream().map(CriteriaResponse::new).toList()); } public PersistenceErrorResponse(String id, PersistenceException e) { diff --git a/src/main/java/ee/bitweb/core/api/model/exception/ValidationErrorResponse.java b/src/main/java/ee/bitweb/core/api/model/exception/ValidationErrorResponse.java index dba921e..9abb94f 100644 --- a/src/main/java/ee/bitweb/core/api/model/exception/ValidationErrorResponse.java +++ b/src/main/java/ee/bitweb/core/api/model/exception/ValidationErrorResponse.java @@ -4,7 +4,6 @@ import java.util.Collection; import java.util.TreeSet; -import java.util.stream.Collectors; import ee.bitweb.core.exception.validation.ValidationException; @@ -19,6 +18,6 @@ public ValidationErrorResponse(String id, String message, Collection value = new HashMap<>(); @@ -80,7 +80,7 @@ private String createHeaderValues(HttpServletRequest request, String key) { if (!headerValues.hasMoreElements()) return null; while (headerValues.hasMoreElements()) { - if (builder.length() != 0) { + if (!builder.isEmpty()) { builder.append("|"); } String headerValue = headerValues.nextElement(); diff --git a/src/main/java/ee/bitweb/core/audit/mappers/RequestHeadersMapper.java b/src/main/java/ee/bitweb/core/audit/mappers/RequestHeadersMapper.java index f97b1d6..52ab0ea 100644 --- a/src/main/java/ee/bitweb/core/audit/mappers/RequestHeadersMapper.java +++ b/src/main/java/ee/bitweb/core/audit/mappers/RequestHeadersMapper.java @@ -1,6 +1,6 @@ package ee.bitweb.core.audit.mappers; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import ee.bitweb.core.audit.AuditLogProperties; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -17,7 +17,7 @@ public class RequestHeadersMapper implements AuditLogDataMapper { private final AuditLogProperties properties; - private final ObjectMapper mapper; + private final JsonMapper mapper; public static final String KEY = "request_headers"; @@ -51,7 +51,7 @@ private String createHeaderValues(HttpServletRequest request, String key) { if (!headerValues.hasMoreElements()) return null; while (headerValues.hasMoreElements()) { - if (builder.length() != 0) { + if (!builder.isEmpty()) { builder.append("|"); } String headerValue = headerValues.nextElement(); diff --git a/src/main/java/ee/bitweb/core/audit/writers/AuditLogLoggerWriterAdapter.java b/src/main/java/ee/bitweb/core/audit/writers/AuditLogLoggerWriterAdapter.java index d56bd7b..37836f5 100644 --- a/src/main/java/ee/bitweb/core/audit/writers/AuditLogLoggerWriterAdapter.java +++ b/src/main/java/ee/bitweb/core/audit/writers/AuditLogLoggerWriterAdapter.java @@ -48,6 +48,11 @@ public void write(Map container) { private void log(Map container) { MDC.setContextMap(container); + + if (!log.isInfoEnabled()) { + return; + } + log.info( "Method({}), URL({}) Status({}) ResponseSize({}) Duration({} ms)", get(container, RequestMethodMapper.KEY), diff --git a/src/main/java/ee/bitweb/core/exception/validation/InvalidFormatValidationException.java b/src/main/java/ee/bitweb/core/exception/validation/InvalidFormatValidationException.java index 085df07..5831f5e 100644 --- a/src/main/java/ee/bitweb/core/exception/validation/InvalidFormatValidationException.java +++ b/src/main/java/ee/bitweb/core/exception/validation/InvalidFormatValidationException.java @@ -3,12 +3,12 @@ import java.util.ArrayList; import java.util.List; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.exc.InvalidFormatException; -import com.fasterxml.jackson.databind.exc.MismatchedInputException; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.util.StringUtils; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.exc.InvalidFormatException; +import tools.jackson.databind.exc.MismatchedInputException; /** * InvalidFormatValidationException.class encapsulates InvalidFormatException in order to gain access to path, value @@ -16,18 +16,18 @@ */ @Slf4j @Getter -public class InvalidFormatValidationException extends InvalidFormatException { +public class InvalidFormatValidationException extends RuntimeException { public static final String UNKNOWN_VALUE = "Unknown"; private final String field; - private final Object value; + private final transient Object value; private final Class targetClass; public InvalidFormatValidationException(InvalidFormatException exception) { - super((JsonParser) exception.getProcessor(), exception.getMessage(), exception.getValue(), exception.getTargetType()); + super(exception.getMessage(), exception); value = exception.getValue(); field = parseFieldName(exception.getPath()); @@ -35,22 +35,18 @@ public InvalidFormatValidationException(InvalidFormatException exception) { } public InvalidFormatValidationException(MismatchedInputException exception) { - super( - (JsonParser) exception.getProcessor(), - exception.getMessage(), - UNKNOWN_VALUE, - exception.getTargetType() - ); + super(exception.getMessage(), exception); + value = UNKNOWN_VALUE; field = parseFieldName(exception.getPath()); targetClass = exception.getTargetType(); } - private String parseFieldName(List references) { + private String parseFieldName(List references) { ArrayList fieldNames = new ArrayList<>(); - for (Reference r : references) { - if (StringUtils.hasText(r.getFieldName())) { - fieldNames.add(r.getFieldName()); + for (JacksonException.Reference r : references) { + if (StringUtils.hasText(r.getPropertyName())) { + fieldNames.add(r.getPropertyName()); } } diff --git a/src/main/java/ee/bitweb/core/object_mapper/ObjectMapperAutoConfiguration.java b/src/main/java/ee/bitweb/core/object_mapper/ObjectMapperAutoConfiguration.java index 6cd1316..e3a5247 100644 --- a/src/main/java/ee/bitweb/core/object_mapper/ObjectMapperAutoConfiguration.java +++ b/src/main/java/ee/bitweb/core/object_mapper/ObjectMapperAutoConfiguration.java @@ -1,32 +1,74 @@ package ee.bitweb.core.object_mapper; -import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import ee.bitweb.core.object_mapper.deserializer.Jackson2TrimmedStringDeserializer; import ee.bitweb.core.object_mapper.deserializer.TrimmedStringDeserializer; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.cfg.DateTimeFeature; +/** + * Auto-configuration for ObjectMapper (Jackson 2) and JsonMapper (Jackson 3). + * + *

Both are configured with identical behavior:

+ *
    + *
  • TrimmedStringDeserializer - trims whitespace from all string fields
  • + *
  • ADJUST_DATES_TO_CONTEXT_TIME_ZONE disabled
  • + *
  • ACCEPT_FLOAT_AS_INT disabled
  • + *
+ * + *

Jackson 2 ObjectMapper is required for Retrofit's converter-jackson.

+ *

Jackson 3 JsonMapper is used by Spring Boot 4.

+ */ @Slf4j @Configuration -@RequiredArgsConstructor @EnableConfigurationProperties({ObjectMapperProperties.class}) @ConditionalOnProperty(value = ObjectMapperProperties.PREFIX + ".auto-configuration", havingValue = "true") public class ObjectMapperAutoConfiguration { - private final ObjectMapper mapper; + /** + * Jackson 3 JsonMapper customizer for Spring Boot 4. + * Extends Spring Boot's auto-configured JsonMapper. + */ + @Bean + public JsonMapperBuilderCustomizer coreLibJsonMapperCustomizer() { + log.info("Applying Core Library JsonMapper (Jackson 3) customizations"); - @PostConstruct - public void init() { - log.info("ObjectMapper AutoConfiguring executed"); + return builder -> builder + .addModule(TrimmedStringDeserializer.createModule()) + .disable(DateTimeFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE) + .disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT); + } + + /** + * Jackson 2 ObjectMapper customizer for Retrofit compatibility. + * Configures the ObjectMapper bean with the same behavior as JsonMapper. + */ + @Slf4j + @Configuration + @RequiredArgsConstructor + @ConditionalOnBean(ObjectMapper.class) + static class Jackson2ObjectMapperCustomizer { + + private final ObjectMapper objectMapper; + + @PostConstruct + public void customize() { + log.info("Applying Core Library ObjectMapper (Jackson 2) customizations"); - TrimmedStringDeserializer.addToObjectMapper(mapper); - mapper.registerModule(new JavaTimeModule()); - mapper.disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE); - mapper.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT); + Jackson2TrimmedStringDeserializer.addToObjectMapper(objectMapper); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(com.fasterxml.jackson.databind.DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE); + objectMapper.disable(com.fasterxml.jackson.databind.DeserializationFeature.ACCEPT_FLOAT_AS_INT); + } } } diff --git a/src/main/java/ee/bitweb/core/object_mapper/ObjectMapperProperties.java b/src/main/java/ee/bitweb/core/object_mapper/ObjectMapperProperties.java index 66bffa3..3a883b5 100644 --- a/src/main/java/ee/bitweb/core/object_mapper/ObjectMapperProperties.java +++ b/src/main/java/ee/bitweb/core/object_mapper/ObjectMapperProperties.java @@ -2,7 +2,6 @@ import lombok.Getter; import lombok.Setter; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -16,5 +15,5 @@ public class ObjectMapperProperties { static final String PREFIX = "ee.bitweb.core.object-mapper"; - private Boolean autoConfiguration = false; + private boolean autoConfiguration = false; } diff --git a/src/main/java/ee/bitweb/core/object_mapper/deserializer/Jackson2TrimmedStringDeserializer.java b/src/main/java/ee/bitweb/core/object_mapper/deserializer/Jackson2TrimmedStringDeserializer.java new file mode 100644 index 0000000..879def2 --- /dev/null +++ b/src/main/java/ee/bitweb/core/object_mapper/deserializer/Jackson2TrimmedStringDeserializer.java @@ -0,0 +1,35 @@ +package ee.bitweb.core.object_mapper.deserializer; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.std.StringDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import ee.bitweb.core.util.StringUtil; + +import java.io.IOException; + +/** + * Jackson 2.x deserializer that trims whitespace from all string fields. + * Required for Retrofit's converter-jackson which uses Jackson 2. + * + * @see TrimmedStringDeserializer for Jackson 3.x version + */ +public class Jackson2TrimmedStringDeserializer extends StringDeserializer { + + @Override + public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + return StringUtil.trim(super.deserialize(p, ctxt)); + } + + public static SimpleModule createModule() { + SimpleModule module = new SimpleModule(); + module.addDeserializer(String.class, new Jackson2TrimmedStringDeserializer()); + + return module; + } + + public static void addToObjectMapper(ObjectMapper mapper) { + mapper.registerModule(createModule()); + } +} diff --git a/src/main/java/ee/bitweb/core/object_mapper/deserializer/TrimmedStringDeserializer.java b/src/main/java/ee/bitweb/core/object_mapper/deserializer/TrimmedStringDeserializer.java index 4f1da64..046f6ab 100644 --- a/src/main/java/ee/bitweb/core/object_mapper/deserializer/TrimmedStringDeserializer.java +++ b/src/main/java/ee/bitweb/core/object_mapper/deserializer/TrimmedStringDeserializer.java @@ -1,25 +1,31 @@ package ee.bitweb.core.object_mapper.deserializer; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.deser.std.StringDeserializer; -import com.fasterxml.jackson.databind.module.SimpleModule; +import ee.bitweb.core.util.StringUtil; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.deser.std.StdScalarDeserializer; +import tools.jackson.databind.module.SimpleModule; -import java.io.IOException; +/** + * Jackson 3.x deserializer that trims whitespace from all string fields. + * + * @see Jackson2TrimmedStringDeserializer for Jackson 2.x version (Retrofit compatibility) + */ +public class TrimmedStringDeserializer extends StdScalarDeserializer { -public class TrimmedStringDeserializer extends StringDeserializer { + public TrimmedStringDeserializer() { + super(String.class); + } @Override - public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { - String value = super.deserialize(p, ctxt); - - return value != null ? value.trim() : null; + public String deserialize(JsonParser p, DeserializationContext ctxt) { + return StringUtil.trim(p.getString()); } - public static void addToObjectMapper(ObjectMapper mapper) { + public static SimpleModule createModule() { SimpleModule module = new SimpleModule(); module.addDeserializer(String.class, new TrimmedStringDeserializer()); - mapper.registerModule(module); + + return module; } } diff --git a/src/main/java/ee/bitweb/core/trace/invoker/amqp/AmqpTraceAdvisor.java b/src/main/java/ee/bitweb/core/trace/invoker/amqp/AmqpTraceAdvisor.java index 97ba3a0..4b1d652 100644 --- a/src/main/java/ee/bitweb/core/trace/invoker/amqp/AmqpTraceAdvisor.java +++ b/src/main/java/ee/bitweb/core/trace/invoker/amqp/AmqpTraceAdvisor.java @@ -23,9 +23,9 @@ public Object invoke(MethodInvocation invocation) throws Throwable { log.debug("Attempting to resolve trace id from Incoming message."); for (Object argument : invocation.getArguments()) { - if (argument instanceof Message) { + if (argument instanceof Message message) { log.debug("Found Message object argument list, invoking trace resolution."); - resolver.resolve((Message) argument); + resolver.resolve(message); } } try { diff --git a/src/main/java/ee/bitweb/core/trace/thread/MDCTaskDecorator.java b/src/main/java/ee/bitweb/core/trace/thread/MDCTaskDecorator.java deleted file mode 100644 index 9910deb..0000000 --- a/src/main/java/ee/bitweb/core/trace/thread/MDCTaskDecorator.java +++ /dev/null @@ -1,14 +0,0 @@ -package ee.bitweb.core.trace.thread; - -import ee.bitweb.core.trace.thread.decorator.SecurityAwareMDCTaskDecorator; - -/** - * @deprecated use BasicMDCTaskDecorator or SecurityAwareMDCTaskDecorator - */ -@Deprecated(since = "3.3.0", forRemoval = true) -public class MDCTaskDecorator extends SecurityAwareMDCTaskDecorator { - - public MDCTaskDecorator(ThreadTraceIdResolver resolver) { - super(resolver); - } -} diff --git a/src/main/java/ee/bitweb/core/util/StringUtil.java b/src/main/java/ee/bitweb/core/util/StringUtil.java index 918388b..88d8400 100644 --- a/src/main/java/ee/bitweb/core/util/StringUtil.java +++ b/src/main/java/ee/bitweb/core/util/StringUtil.java @@ -11,6 +11,16 @@ public final class StringUtil { private static final String CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"; private static final SecureRandom RANDOM = new SecureRandom(); + /** + * Trims whitespace from the given string value. + * + * @param value the string to trim, may be null + * @return trimmed string, or null if input was null + */ + public static String trim(String value) { + return value != null ? value.trim() : null; + } + public static String random(int length) { var sb = new StringBuilder(); diff --git a/src/test/java/ee/bitweb/core/TestSpringApplication.java b/src/test/java/ee/bitweb/core/TestSpringApplication.java index 2912a59..9f31577 100644 --- a/src/test/java/ee/bitweb/core/TestSpringApplication.java +++ b/src/test/java/ee/bitweb/core/TestSpringApplication.java @@ -1,14 +1,9 @@ package ee.bitweb.core; - -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import tools.jackson.databind.json.JsonMapper; import ee.bitweb.core.trace.creator.TraceIdCreator; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; import org.springframework.boot.SpringApplication; -import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; +import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -28,17 +23,25 @@ public static void main(String[] args) { SpringApplication.run(TestSpringApplication.class); } - @RequiredArgsConstructor @org.springframework.context.annotation.Configuration public static class Configuration { - private final ObjectMapper mapper; + @Bean + public tools.jackson.databind.ObjectMapper jackson3ObjectMapper() { + // Jackson 3.x for Spring Boot 4's internal use + return JsonMapper.builder() + .disable(tools.jackson.databind.DeserializationFeature.ACCEPT_FLOAT_AS_INT) + .build(); + } - @PostConstruct - public void init() { - mapper.registerModule(new JavaTimeModule()); - mapper.disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE); - mapper.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT); + @Bean + public com.fasterxml.jackson.databind.ObjectMapper objectMapper() { + // Jackson 2.x for test compatibility + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()); + mapper.disable(com.fasterxml.jackson.databind.DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE); + mapper.disable(com.fasterxml.jackson.databind.DeserializationFeature.ACCEPT_FLOAT_AS_INT); + return mapper; } @Bean("InvokerTraceIdCreator") @@ -60,7 +63,7 @@ public String generate(String traceId) { public static class SecurityConfiguration { @Bean - protected SecurityFilterChain configure(HttpSecurity httpSecurity) throws Exception { + protected SecurityFilterChain configure(HttpSecurity httpSecurity) { // Configure security to allow any request other than actuator requests return httpSecurity diff --git a/src/test/java/ee/bitweb/core/actuator/ActuatorSecurityIntegrationTests.java b/src/test/java/ee/bitweb/core/actuator/ActuatorSecurityIntegrationTests.java index 6befdee..78339ba 100644 --- a/src/test/java/ee/bitweb/core/actuator/ActuatorSecurityIntegrationTests.java +++ b/src/test/java/ee/bitweb/core/actuator/ActuatorSecurityIntegrationTests.java @@ -6,7 +6,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; diff --git a/src/test/java/ee/bitweb/core/amqp/AmqpMessageListenerTests.java b/src/test/java/ee/bitweb/core/amqp/AmqpMessageListenerTests.java index 983dc58..e948b28 100644 --- a/src/test/java/ee/bitweb/core/amqp/AmqpMessageListenerTests.java +++ b/src/test/java/ee/bitweb/core/amqp/AmqpMessageListenerTests.java @@ -37,7 +37,7 @@ class AmqpMessageListenerTests { private AmqpTestHelper amqpTestHelper; @AfterEach - public void cleanup() { + void cleanup() { amqpTestHelper.clear(AmqpConfig.COMMAND_QUEUE_NAME); amqpTestHelper.clear(AmqpConfig.COMMAND_QUEUE_SIMPLE_NAME); amqpTestHelper.clear(AmqpConfig.COMMAND_DEAD_LETTER_EXCHANGE_NAME); diff --git a/src/test/java/ee/bitweb/core/amqp/testcomponents/util/AmqpParsedMessage.java b/src/test/java/ee/bitweb/core/amqp/testcomponents/util/AmqpParsedMessage.java index 570186d..6db9698 100644 --- a/src/test/java/ee/bitweb/core/amqp/testcomponents/util/AmqpParsedMessage.java +++ b/src/test/java/ee/bitweb/core/amqp/testcomponents/util/AmqpParsedMessage.java @@ -6,8 +6,8 @@ @Getter @AllArgsConstructor -public class AmqpParsedMessage { +public class AmqpParsedMessage { private Message message; - private Body body; + private B body; } diff --git a/src/test/java/ee/bitweb/core/amqp/testcomponents/util/AmqpTestHelper.java b/src/test/java/ee/bitweb/core/amqp/testcomponents/util/AmqpTestHelper.java index 9bd284b..ca3249f 100644 --- a/src/test/java/ee/bitweb/core/amqp/testcomponents/util/AmqpTestHelper.java +++ b/src/test/java/ee/bitweb/core/amqp/testcomponents/util/AmqpTestHelper.java @@ -1,6 +1,6 @@ package ee.bitweb.core.amqp.testcomponents.util; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import ee.bitweb.core.amqp.AmqpService; import org.springframework.amqp.core.AmqpAdmin; import org.springframework.amqp.core.Message; @@ -11,7 +11,6 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import org.testcontainers.shaded.org.awaitility.Awaitility; -import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; @@ -30,7 +29,7 @@ public class AmqpTestHelper { private RabbitTemplate rabbitTemplate; @Autowired - private ObjectMapper mapper; + private JsonMapper mapper; public Queue createQueue() { return admin.declareQueue(); @@ -38,14 +37,14 @@ public Queue createQueue() { public void waitForResponse(String responseQueue, int size){ Awaitility.await().atMost(10, TimeUnit.SECONDS).until(() -> { - Integer count = getMessageCount(responseQueue); + Long count = getMessageCount(responseQueue); return count >= size; }); } public void waitForEmptyQueue(String queueName) { Awaitility.await().atMost(10, TimeUnit.SECONDS).until(() -> { - Integer count = getMessageCount(queueName); + Long count = getMessageCount(queueName); return count == 0; }); } @@ -92,11 +91,11 @@ public List getResponse(String responseQueue, int size) { return result; } - public AmqpParsedMessage convert(Message message, Class clazz) throws IOException { + public AmqpParsedMessage convert(Message message, Class clazz) { return new AmqpParsedMessage(message, mapper.readValue(new String(message.getBody()), clazz)); } - public List> convert(List messages, Class clazz) throws IOException { + public List> convert(List messages, Class clazz) { List> response = new ArrayList<>(); for (Message message : messages) { response.add(convert(message, clazz)); @@ -104,7 +103,7 @@ public List> convert(List messages, Class c return response; } - public Integer getMessageCount(String queueName) { - return (Integer) admin.getQueueProperties(queueName).get(RabbitAdmin.QUEUE_MESSAGE_COUNT); + public Long getMessageCount(String queueName) { + return (Long) admin.getQueueProperties(queueName).get(RabbitAdmin.QUEUE_MESSAGE_COUNT); } } diff --git a/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorCompleteFileNamesIntegrationTests.java b/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorCompleteFileNamesIntegrationTests.java index 23cc243..0044009 100644 --- a/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorCompleteFileNamesIntegrationTests.java +++ b/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorCompleteFileNamesIntegrationTests.java @@ -8,7 +8,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; diff --git a/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorIntegrationTests.java b/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorIntegrationTests.java index 71ebc6e..b4467b1 100644 --- a/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorIntegrationTests.java +++ b/src/test/java/ee/bitweb/core/api/model/exception/ControllerAdvisorIntegrationTests.java @@ -14,7 +14,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.*; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.*; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; @@ -277,9 +277,8 @@ void onInvalidContentTypeWithMultipartRequestShouldReturnBadRequestError() throw @Test void onMissingMultipartRequestPartShouldReturnBadRequestError() throws Exception { - MockHttpServletRequestBuilder mockMvcBuilder = - multipart(TestPingController.BASE_URL + "/import") - .header(TRACE_ID_HEADER_NAME, "1234567890"); + var mockMvcBuilder = multipart(TestPingController.BASE_URL + "/import") + .header(TRACE_ID_HEADER_NAME, "1234567890"); ResultActions result = mockMvc.perform(mockMvcBuilder).andDo(print()); ResponseAssertions.assertValidationErrorResponse( @@ -355,10 +354,9 @@ void onInvalidIntegerFieldValueShouldReturnBadRequest() throws Exception { String val = "2.9"; String reason = InvalidFormatExceptionConverter.INVALID_FORMAT_REASON; String message = format(InvalidFormatExceptionConverter.INVALID_INTEGER_VALUE_MESSAGE, val); - String messageValueUnknown = format(InvalidFormatExceptionConverter.INVALID_INTEGER_VALUE_MESSAGE, "2.9"); - testFieldPost("intField", val, reason, messageValueUnknown); - testFieldPost("longField", val, reason, messageValueUnknown); + // Note: In Jackson 3.x / Spring Boot 4, numeric 2.9 is accepted and truncated to 2. + // Only quoted string values like "2.9" are rejected as invalid integer format. testFieldPost("intField", "\"" + val + "\"", reason, message); testFieldPost("longField", "\"" + val + "\"", reason, message); diff --git a/src/test/java/ee/bitweb/core/api/model/exception/ValidationErrorResponseTest.java b/src/test/java/ee/bitweb/core/api/model/exception/ValidationErrorResponseTest.java index 1a8a429..48cd179 100644 --- a/src/test/java/ee/bitweb/core/api/model/exception/ValidationErrorResponseTest.java +++ b/src/test/java/ee/bitweb/core/api/model/exception/ValidationErrorResponseTest.java @@ -1,7 +1,6 @@ package ee.bitweb.core.api.model.exception; import ee.bitweb.core.api.ValidationErrorType; -import ee.bitweb.core.exception.validation.FieldError; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; diff --git a/src/test/java/ee/bitweb/core/audit/AuditLogAutoconfigurationEnabledTests.java b/src/test/java/ee/bitweb/core/audit/AuditLogAutoconfigurationEnabledTests.java index 17afa64..50cdd6e 100644 --- a/src/test/java/ee/bitweb/core/audit/AuditLogAutoconfigurationEnabledTests.java +++ b/src/test/java/ee/bitweb/core/audit/AuditLogAutoconfigurationEnabledTests.java @@ -11,7 +11,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; @@ -49,7 +49,7 @@ class AuditLogAutoconfigurationEnabledTests { private CustomAuditLogWriter writer; @BeforeEach - public void init() { + void init() { writer.reset(); } diff --git a/src/test/java/ee/bitweb/core/audit/AuditLogConfigurationTests.java b/src/test/java/ee/bitweb/core/audit/AuditLogConfigurationTests.java index caa7e66..bd796ee 100644 --- a/src/test/java/ee/bitweb/core/audit/AuditLogConfigurationTests.java +++ b/src/test/java/ee/bitweb/core/audit/AuditLogConfigurationTests.java @@ -9,7 +9,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; @@ -51,7 +51,7 @@ class AuditLogConfigurationTests { private CustomAuditLogWriter writer; @BeforeEach - public void beforeEachC() { + void beforeEachC() { writer.reset(); } diff --git a/src/test/java/ee/bitweb/core/audit/mapper/RequestForwardingDataMapperUnitTests.java b/src/test/java/ee/bitweb/core/audit/mapper/RequestForwardingDataMapperUnitTests.java index 34d5b56..06f50a3 100644 --- a/src/test/java/ee/bitweb/core/audit/mapper/RequestForwardingDataMapperUnitTests.java +++ b/src/test/java/ee/bitweb/core/audit/mapper/RequestForwardingDataMapperUnitTests.java @@ -1,7 +1,7 @@ package ee.bitweb.core.audit.mapper; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; import ee.bitweb.core.audit.AuditLogProperties; import ee.bitweb.core.audit.mappers.RequestForwardingDataMapper; import org.junit.jupiter.api.Tag; @@ -19,11 +19,11 @@ @ExtendWith(MockitoExtension.class) class RequestForwardingDataMapperUnitTests { - private final ObjectMapper mapper = new ObjectMapper(); + private final JsonMapper mapper = JsonMapper.builder().build(); private final AuditLogProperties properties = new AuditLogProperties(); @Test - void testAddIpAddressesUsesXForwardedForHeader() throws JsonProcessingException { + void testAddIpAddressesUsesXForwardedForHeader() throws JacksonException { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/this"); request.addHeader("x-forwarded-for", "192.168.69.145,192.168.69.1"); @@ -35,7 +35,7 @@ void testAddIpAddressesUsesXForwardedForHeader() throws JsonProcessingException } @Test - void testAddIpAddressesUsesXForwardedForHeaders() throws JsonProcessingException { + void testAddIpAddressesUsesXForwardedForHeaders() throws JacksonException { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/this"); request.addHeader("x-forwarded-for", "192.168.69.145"); request.addHeader("x-forwarded-for", "192.168.69.1"); @@ -49,7 +49,7 @@ void testAddIpAddressesUsesXForwardedForHeaders() throws JsonProcessingException } @Test - void testAddForwardingHeadersParsesForwardedHeader() throws JsonProcessingException { + void testAddForwardingHeadersParsesForwardedHeader() throws JacksonException { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/this"); request.addHeader("forwarded", "for=192.0.2.43,for=198.51.100.17;by=203.0.113.60;proto=http;host=example.com;secret=ruewiu"); @@ -66,7 +66,7 @@ void testAddForwardingHeadersParsesForwardedHeader() throws JsonProcessingExcept } @Test - void testSensitiveHeaderSettingAppliedOnForwardedHeaders() throws JsonProcessingException { + void testSensitiveHeaderSettingAppliedOnForwardedHeaders() throws JacksonException { AuditLogProperties properties = new AuditLogProperties(); properties.getSensitiveHeaders().add("forwarded"); diff --git a/src/test/java/ee/bitweb/core/audit/mapper/RequestHeadersMapperUnitTests.java b/src/test/java/ee/bitweb/core/audit/mapper/RequestHeadersMapperUnitTests.java index d3f86ca..470ea78 100644 --- a/src/test/java/ee/bitweb/core/audit/mapper/RequestHeadersMapperUnitTests.java +++ b/src/test/java/ee/bitweb/core/audit/mapper/RequestHeadersMapperUnitTests.java @@ -1,7 +1,7 @@ package ee.bitweb.core.audit.mapper; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; import ee.bitweb.core.audit.AuditLogProperties; import ee.bitweb.core.audit.mappers.RequestHeadersMapper; import org.junit.jupiter.api.Tag; @@ -19,12 +19,12 @@ @ExtendWith(MockitoExtension.class) class RequestHeadersMapperUnitTests { - private final ObjectMapper mapper = new ObjectMapper(); + private final JsonMapper mapper = JsonMapper.builder().build(); private final AuditLogProperties properties = new AuditLogProperties(); private final MockHttpServletResponse response = new MockHttpServletResponse(); @Test - void originIsLoggedByDefault() throws JsonProcessingException { + void originIsLoggedByDefault() throws JacksonException { MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader("Origin", "http://whatever.example"); request.addHeader("Random", "This is unnecessary data"); @@ -37,7 +37,7 @@ void originIsLoggedByDefault() throws JsonProcessingException { } @Test - void userAgentIsLoggedByDefault() throws JsonProcessingException { + void userAgentIsLoggedByDefault() throws JacksonException { MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader("User-Agent", "Chrome"); request.addHeader("Random", "This is unnecessary data"); @@ -50,7 +50,7 @@ void userAgentIsLoggedByDefault() throws JsonProcessingException { } @Test - void authorizationHeaderIsSensitiveByDefault() throws JsonProcessingException { + void authorizationHeaderIsSensitiveByDefault() throws JacksonException { AuditLogProperties properties = new AuditLogProperties(); properties.getRequestHeaders().add("Authorization"); diff --git a/src/test/java/ee/bitweb/core/audit/testcomponent/AuditLogController.java b/src/test/java/ee/bitweb/core/audit/testcomponent/AuditLogController.java index 5a6fbfe..22a69c4 100644 --- a/src/test/java/ee/bitweb/core/audit/testcomponent/AuditLogController.java +++ b/src/test/java/ee/bitweb/core/audit/testcomponent/AuditLogController.java @@ -29,7 +29,7 @@ public SimpleValidatedObject getValidated(@RequestBody @Valid SimpleValidatedObj @GetMapping("/ignored") public void ignored() { - + // void endpoint } @Getter diff --git a/src/test/java/ee/bitweb/core/audit/testcomponent/CustomAuditLogMapper.java b/src/test/java/ee/bitweb/core/audit/testcomponent/CustomAuditLogMapper.java index 3d2f3ea..dd93360 100644 --- a/src/test/java/ee/bitweb/core/audit/testcomponent/CustomAuditLogMapper.java +++ b/src/test/java/ee/bitweb/core/audit/testcomponent/CustomAuditLogMapper.java @@ -11,7 +11,7 @@ @Profile("CustomAuditLogMapper") public class CustomAuditLogMapper implements AuditLogDataMapper { - public static String KEY = "RANDOM_KEY"; + public static final String KEY = "RANDOM_KEY"; public String getValue(HttpServletRequest request, HttpServletResponse response) { return "SOME_RANDOM_VALUE"; diff --git a/src/test/java/ee/bitweb/core/exception/persistence/ConflictExceptionTest.java b/src/test/java/ee/bitweb/core/exception/persistence/ConflictExceptionTest.java new file mode 100644 index 0000000..0e5702e --- /dev/null +++ b/src/test/java/ee/bitweb/core/exception/persistence/ConflictExceptionTest.java @@ -0,0 +1,92 @@ +package ee.bitweb.core.exception.persistence; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("unit") +class ConflictExceptionTest { + + @Test + @DisplayName("Should return HTTP 409 status code") + void shouldReturnConflictStatusCode() { + ConflictException exception = new ConflictException("Conflict", "User", "email", "test@example.com"); + + assertEquals(409, exception.getCode()); + } + + @Test + @DisplayName("CODE constant should be 409") + void codeConstantShouldBe409() { + assertEquals(409, ConflictException.CODE); + } + + @Test + @DisplayName("Should create with message and single criteria") + void shouldCreateWithMessageAndSingleCriteria() { + ConflictException exception = new ConflictException( + "Email already exists", + "User", + "email", + "test@example.com" + ); + + assertAll( + () -> assertEquals("Email already exists", exception.getMessage()), + () -> assertEquals("User", exception.getEntity()), + () -> assertEquals(1, exception.getCriteria().size()) + ); + + Criteria criteria = exception.getCriteria().iterator().next(); + assertAll( + () -> assertEquals("email", criteria.getField()), + () -> assertEquals("test@example.com", criteria.getValue()) + ); + } + + @Test + @DisplayName("Should create with message and multiple criteria") + void shouldCreateWithMessageAndMultipleCriteria() { + Set criteria = Set.of( + new Criteria("email", "test@example.com"), + new Criteria("username", "testuser") + ); + + ConflictException exception = new ConflictException( + "User already exists", + "User", + criteria + ); + + assertAll( + () -> assertEquals("User already exists", exception.getMessage()), + () -> assertEquals("User", exception.getEntity()), + () -> assertEquals(2, exception.getCriteria().size()) + ); + } + + @Test + @DisplayName("Should generate default message when custom message is empty") + void shouldGenerateDefaultMessageWhenEmpty() { + Set criteria = Set.of(new Criteria("id", "123")); + + ConflictException exception = new ConflictException("", "User", criteria); + + assertTrue(exception.getMessage().contains("User")); + assertTrue(exception.getMessage().contains("Exception with entity")); + } + + @Test + @DisplayName("Should generate default message when custom message is null") + void shouldGenerateDefaultMessageWhenNull() { + Set criteria = Set.of(new Criteria("id", "123")); + + ConflictException exception = new ConflictException(null, "User", criteria); + + assertTrue(exception.getMessage().contains("User")); + } +} diff --git a/src/test/java/ee/bitweb/core/exception/persistence/EntityNotFoundExceptionTest.java b/src/test/java/ee/bitweb/core/exception/persistence/EntityNotFoundExceptionTest.java new file mode 100644 index 0000000..9d104b7 --- /dev/null +++ b/src/test/java/ee/bitweb/core/exception/persistence/EntityNotFoundExceptionTest.java @@ -0,0 +1,108 @@ +package ee.bitweb.core.exception.persistence; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.HttpStatus; + +import java.util.Set; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("unit") +class EntityNotFoundExceptionTest { + + @Test + @DisplayName("Should return HTTP 404 status code") + void shouldReturnNotFoundStatusCode() { + EntityNotFoundException exception = new EntityNotFoundException("User", "id", "123"); + + assertEquals(HttpStatus.NOT_FOUND.value(), exception.getCode()); + } + + @Test + @DisplayName("Should create with custom message and single criteria") + void shouldCreateWithCustomMessageAndSingleCriteria() { + EntityNotFoundException exception = new EntityNotFoundException( + "Custom message", + "User", + "id", + "123" + ); + + assertAll( + () -> assertEquals("Custom message", exception.getMessage()), + () -> assertEquals("User", exception.getEntity()), + () -> assertEquals(1, exception.getCriteria().size()) + ); + } + + @Test + @DisplayName("Should create with custom message and multiple criteria") + void shouldCreateWithCustomMessageAndMultipleCriteria() { + Set criteria = Set.of( + new Criteria("id", "123"), + new Criteria("status", "ACTIVE") + ); + + EntityNotFoundException exception = new EntityNotFoundException( + "Custom message", + "User", + criteria + ); + + assertAll( + () -> assertEquals("Custom message", exception.getMessage()), + () -> assertEquals("User", exception.getEntity()), + () -> assertEquals(2, exception.getCriteria().size()) + ); + } + + @Test + @DisplayName("Should generate default message when using entity and criteria") + void shouldGenerateDefaultMessageWithEntityAndCriteria() { + EntityNotFoundException exception = new EntityNotFoundException("User", "id", "123"); + + assertEquals("Entity User not found", exception.getMessage()); + } + + @Test + @DisplayName("Should generate default message with multiple criteria") + void shouldGenerateDefaultMessageWithMultipleCriteria() { + Set criteria = Set.of( + new Criteria("id", "123"), + new Criteria("email", "test@example.com") + ); + + EntityNotFoundException exception = new EntityNotFoundException("User", criteria); + + assertEquals("Entity User not found", exception.getMessage()); + } + + static Stream criteriaTestCases() { + return Stream.of( + Arguments.of("User", "id", "123", "id", "123"), + Arguments.of("Order", "orderId", "ABC", "orderId", "ABC"), + Arguments.of("Product", "sku", "SKU-001", "sku", "SKU-001") + ); + } + + @ParameterizedTest(name = "Entity {0} with field {1}={2}") + @MethodSource("criteriaTestCases") + void shouldStoreCriteriaCorrectly(String entity, String field, String value, + String expectedField, String expectedValue) { + EntityNotFoundException exception = new EntityNotFoundException(entity, field, value); + + Criteria criteria = exception.getCriteria().iterator().next(); + + assertAll( + () -> assertEquals(entity, exception.getEntity()), + () -> assertEquals(expectedField, criteria.getField()), + () -> assertEquals(expectedValue, criteria.getValue()) + ); + } +} diff --git a/src/test/java/ee/bitweb/core/exception/validation/InvalidFormatValidationExceptionTest.java b/src/test/java/ee/bitweb/core/exception/validation/InvalidFormatValidationExceptionTest.java new file mode 100644 index 0000000..5b95526 --- /dev/null +++ b/src/test/java/ee/bitweb/core/exception/validation/InvalidFormatValidationExceptionTest.java @@ -0,0 +1,158 @@ +package ee.bitweb.core.exception.validation; + +import tools.jackson.databind.exc.InvalidFormatException; +import tools.jackson.databind.exc.MismatchedInputException; +import tools.jackson.databind.json.JsonMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("unit") +class InvalidFormatValidationExceptionTest { + + private final JsonMapper jsonMapper = JsonMapper.builder().build(); + + @Test + @DisplayName("UNKNOWN_VALUE constant should be 'Unknown'") + void unknownValueConstantShouldBeUnknown() { + assertEquals("Unknown", InvalidFormatValidationException.UNKNOWN_VALUE); + } + + @Test + @DisplayName("Should extract field name from InvalidFormatException") + void shouldExtractFieldNameFromInvalidFormatException() { + String json = "{\"status\": \"INVALID_STATUS\"}"; + + InvalidFormatException originalException = assertThrows( + InvalidFormatException.class, + () -> jsonMapper.readValue(json, TestDto.class) + ); + + InvalidFormatValidationException exception = new InvalidFormatValidationException(originalException); + + assertEquals("status", exception.getField()); + } + + @Test + @DisplayName("Should extract value from InvalidFormatException") + void shouldExtractValueFromInvalidFormatException() { + String json = "{\"status\": \"INVALID_STATUS\"}"; + + InvalidFormatException originalException = assertThrows( + InvalidFormatException.class, + () -> jsonMapper.readValue(json, TestDto.class) + ); + + InvalidFormatValidationException exception = new InvalidFormatValidationException(originalException); + + assertEquals("INVALID_STATUS", exception.getValue()); + } + + @Test + @DisplayName("Should extract target class from InvalidFormatException") + void shouldExtractTargetClassFromInvalidFormatException() { + String json = "{\"status\": \"INVALID_STATUS\"}"; + + InvalidFormatException originalException = assertThrows( + InvalidFormatException.class, + () -> jsonMapper.readValue(json, TestDto.class) + ); + + InvalidFormatValidationException exception = new InvalidFormatValidationException(originalException); + + assertEquals(Status.class, exception.getTargetClass()); + } + + @Test + @DisplayName("Should handle nested field names") + void shouldHandleNestedFieldNames() { + String json = "{\"nested\": {\"status\": \"INVALID\"}}"; + + InvalidFormatException originalException = assertThrows( + InvalidFormatException.class, + () -> jsonMapper.readValue(json, WrapperDto.class) + ); + + InvalidFormatValidationException exception = new InvalidFormatValidationException(originalException); + + assertEquals("nested.status", exception.getField()); + } + + @Test + @DisplayName("Should set UNKNOWN_VALUE for MismatchedInputException") + void shouldSetUnknownValueForMismatchedInputException() { + String json = "{\"count\": \"not-a-number\"}"; + + MismatchedInputException originalException = assertThrows( + MismatchedInputException.class, + () -> jsonMapper.readValue(json, IntegerDto.class) + ); + + InvalidFormatValidationException exception = new InvalidFormatValidationException(originalException); + + assertEquals(InvalidFormatValidationException.UNKNOWN_VALUE, exception.getValue()); + } + + @Test + @DisplayName("Should extract field name from MismatchedInputException") + void shouldExtractFieldNameFromMismatchedInputException() { + String json = "{\"count\": \"not-a-number\"}"; + + MismatchedInputException originalException = assertThrows( + MismatchedInputException.class, + () -> jsonMapper.readValue(json, IntegerDto.class) + ); + + InvalidFormatValidationException exception = new InvalidFormatValidationException(originalException); + + assertEquals("count", exception.getField()); + } + + @Test + @DisplayName("Should extract target class from MismatchedInputException") + void shouldExtractTargetClassFromMismatchedInputException() { + String json = "{\"count\": \"not-a-number\"}"; + + MismatchedInputException originalException = assertThrows( + MismatchedInputException.class, + () -> jsonMapper.readValue(json, IntegerDto.class) + ); + + InvalidFormatValidationException exception = new InvalidFormatValidationException(originalException); + + assertEquals(Integer.class, exception.getTargetClass()); + } + + @Test + @DisplayName("Should be instance of RuntimeException") + void shouldBeInstanceOfRuntimeException() { + String json = "{\"status\": \"INVALID\"}"; + + InvalidFormatException originalException = assertThrows( + InvalidFormatException.class, + () -> jsonMapper.readValue(json, TestDto.class) + ); + + InvalidFormatValidationException exception = new InvalidFormatValidationException(originalException); + + assertInstanceOf(RuntimeException.class, exception); + } + + enum Status { + ACTIVE, INACTIVE + } + + static class TestDto { + public Status status; + } + + static class WrapperDto { + public TestDto nested; + } + + static class IntegerDto { + public Integer count; + } +} diff --git a/src/test/java/ee/bitweb/core/exception/validation/ValidationExceptionTest.java b/src/test/java/ee/bitweb/core/exception/validation/ValidationExceptionTest.java new file mode 100644 index 0000000..ae5765a --- /dev/null +++ b/src/test/java/ee/bitweb/core/exception/validation/ValidationExceptionTest.java @@ -0,0 +1,81 @@ +package ee.bitweb.core.exception.validation; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("unit") +class ValidationExceptionTest { + + @Test + @DisplayName("Should create with default message") + void shouldCreateWithDefaultMessage() { + Set errors = Set.of( + new FieldError("email", "NotEmpty", "must not be empty") + ); + + ValidationException exception = new ValidationException(errors); + + assertEquals("Validation failed with errors", exception.getMessage()); + } + + @Test + @DisplayName("Should create with custom message") + void shouldCreateWithCustomMessage() { + Set errors = Set.of( + new FieldError("email", "NotEmpty", "must not be empty") + ); + + ValidationException exception = new ValidationException("Custom validation error", errors); + + assertEquals("Custom validation error", exception.getMessage()); + } + + @Test + @DisplayName("Should store errors") + void shouldStoreErrors() { + FieldError error1 = new FieldError("email", "NotEmpty", "must not be empty"); + FieldError error2 = new FieldError("name", "Size", "must be between 1 and 100"); + Set errors = Set.of(error1, error2); + + ValidationException exception = new ValidationException(errors); + + assertEquals(2, exception.getErrors().size()); + assertTrue(exception.getErrors().contains(error1)); + assertTrue(exception.getErrors().contains(error2)); + } + + @Test + @DisplayName("Should handle single error") + void shouldHandleSingleError() { + FieldError error = new FieldError("password", "Pattern", "must match pattern"); + Set errors = Set.of(error); + + ValidationException exception = new ValidationException(errors); + + assertEquals(1, exception.getErrors().size()); + assertTrue(exception.getErrors().contains(error)); + } + + @Test + @DisplayName("Should handle empty errors set") + void shouldHandleEmptyErrorsSet() { + Set errors = Set.of(); + + ValidationException exception = new ValidationException(errors); + + assertTrue(exception.getErrors().isEmpty()); + } + + @Test + @DisplayName("Should be instance of CoreException") + void shouldBeInstanceOfCoreException() { + ValidationException exception = new ValidationException(Set.of()); + + assertInstanceOf(ee.bitweb.core.exception.CoreException.class, exception); + } +} diff --git a/src/test/java/ee/bitweb/core/object_mapper/ObjectMapperPropertiesTest.java b/src/test/java/ee/bitweb/core/object_mapper/ObjectMapperPropertiesTest.java index 454ceb8..c033122 100644 --- a/src/test/java/ee/bitweb/core/object_mapper/ObjectMapperPropertiesTest.java +++ b/src/test/java/ee/bitweb/core/object_mapper/ObjectMapperPropertiesTest.java @@ -20,7 +20,7 @@ void shouldHaveCorrectPrefixConstant() { void shouldHaveDefaultAutoConfigurationAsFalse() { ObjectMapperProperties properties = new ObjectMapperProperties(); - assertFalse(properties.getAutoConfiguration()); + assertFalse(properties.isAutoConfiguration()); } @Test @@ -30,7 +30,7 @@ void shouldAllowSettingAutoConfigurationToTrue() { properties.setAutoConfiguration(true); - assertTrue(properties.getAutoConfiguration()); + assertTrue(properties.isAutoConfiguration()); } @Test @@ -41,6 +41,6 @@ void shouldAllowSettingAutoConfigurationBackToFalse() { properties.setAutoConfiguration(false); - assertFalse(properties.getAutoConfiguration()); + assertFalse(properties.isAutoConfiguration()); } } diff --git a/src/test/java/ee/bitweb/core/object_mapper/deserializer/TrimmedStringDeserializerTest.java b/src/test/java/ee/bitweb/core/object_mapper/deserializer/TrimmedStringDeserializerTest.java index 08624db..78e305d 100644 --- a/src/test/java/ee/bitweb/core/object_mapper/deserializer/TrimmedStringDeserializerTest.java +++ b/src/test/java/ee/bitweb/core/object_mapper/deserializer/TrimmedStringDeserializerTest.java @@ -1,7 +1,7 @@ package ee.bitweb.core.object_mapper.deserializer; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; @@ -17,12 +17,13 @@ @Tag("unit") class TrimmedStringDeserializerTest { - private ObjectMapper mapper; + private JsonMapper mapper; @BeforeEach void setUp() { - mapper = new ObjectMapper(); - TrimmedStringDeserializer.addToObjectMapper(mapper); + mapper = JsonMapper.builder() + .addModule(TrimmedStringDeserializer.createModule()) + .build(); } static Stream stringTrimmingCases() { @@ -39,7 +40,7 @@ static Stream stringTrimmingCases() { @ParameterizedTest(name = "Should handle {2}") @MethodSource("stringTrimmingCases") - void shouldTrimStrings(String input, String expected, String description) throws JsonProcessingException { + void shouldTrimStrings(String input, String expected, String description) throws JacksonException { String json = "\"" + escapeJson(input) + "\""; String result = mapper.readValue(json, String.class); @@ -49,7 +50,7 @@ void shouldTrimStrings(String input, String expected, String description) throws @Test @DisplayName("Should return null for null value") - void shouldReturnNullForNullValue() throws JsonProcessingException { + void shouldReturnNullForNullValue() throws JacksonException { String result = mapper.readValue("null", String.class); assertNull(result); @@ -57,7 +58,7 @@ void shouldReturnNullForNullValue() throws JsonProcessingException { @Test @DisplayName("Should trim string fields in object") - void shouldTrimStringFieldsInObject() throws JsonProcessingException { + void shouldTrimStringFieldsInObject() throws JacksonException { String json = "{\"name\": \" John Doe \", \"email\": \" john@example.com \"}"; TestObject result = mapper.readValue(json, TestObject.class); @@ -70,7 +71,7 @@ void shouldTrimStringFieldsInObject() throws JsonProcessingException { @Test @DisplayName("Should trim strings in array") - void shouldTrimStringsInArray() throws JsonProcessingException { + void shouldTrimStringsInArray() throws JacksonException { String json = "[\" first \", \" second \", \" third \"]"; String[] result = mapper.readValue(json, String[].class); diff --git a/src/test/java/ee/bitweb/core/retrofit/builder/RetrofitApiBuilderTests.java b/src/test/java/ee/bitweb/core/retrofit/builder/RetrofitApiBuilderTests.java index 06b0270..0228c5d 100644 --- a/src/test/java/ee/bitweb/core/retrofit/builder/RetrofitApiBuilderTests.java +++ b/src/test/java/ee/bitweb/core/retrofit/builder/RetrofitApiBuilderTests.java @@ -171,13 +171,13 @@ void callTimeoutIsApplied() { OkHttpClient.Builder clientBuilder = Mockito.spy(OkHttpClient.Builder.class); AtomicReference clientRef = new AtomicReference<>(); - Mockito.doAnswer((answer) -> { + Mockito.doAnswer(answer -> { OkHttpClient client = (OkHttpClient) answer.callRealMethod(); clientRef.set(client); return client; }).when(clientBuilder).build(); - ExternalServiceApi api = RetrofitApiBuilder + RetrofitApiBuilder .create(BASE_URL + server.getPort(), ExternalServiceApi.class, null) .clientBuilder(clientBuilder) .callTimeout(999) @@ -191,13 +191,13 @@ void connectTimeoutIsApplied() { OkHttpClient.Builder clientBuilder = Mockito.spy(OkHttpClient.Builder.class); AtomicReference clientRef = new AtomicReference<>(); - Mockito.doAnswer((answer) -> { + Mockito.doAnswer(answer -> { OkHttpClient client = (OkHttpClient) answer.callRealMethod(); clientRef.set(client); return client; }).when(clientBuilder).build(); - ExternalServiceApi api = RetrofitApiBuilder + RetrofitApiBuilder .create(BASE_URL + server.getPort(), ExternalServiceApi.class, null) .clientBuilder(clientBuilder) .connectTimeout(999) @@ -211,13 +211,13 @@ void readTimeoutIsApplied() { OkHttpClient.Builder clientBuilder = Mockito.spy(OkHttpClient.Builder.class); AtomicReference clientRef = new AtomicReference<>(); - Mockito.doAnswer((answer) -> { + Mockito.doAnswer(answer -> { OkHttpClient client = (OkHttpClient) answer.callRealMethod(); clientRef.set(client); return client; }).when(clientBuilder).build(); - ExternalServiceApi api = RetrofitApiBuilder + RetrofitApiBuilder .create(BASE_URL + server.getPort(), ExternalServiceApi.class, null) .clientBuilder(clientBuilder) .readTimeout(999) @@ -231,13 +231,13 @@ void writeTimeoutIsApplied() { OkHttpClient.Builder clientBuilder = Mockito.spy(OkHttpClient.Builder.class); AtomicReference clientRef = new AtomicReference<>(); - Mockito.doAnswer((answer) -> { + Mockito.doAnswer(answer -> { OkHttpClient client = (OkHttpClient) answer.callRealMethod(); clientRef.set(client); return client; }).when(clientBuilder).build(); - ExternalServiceApi api = RetrofitApiBuilder + RetrofitApiBuilder .create(BASE_URL + server.getPort(), ExternalServiceApi.class, null) .clientBuilder(clientBuilder) .writeTimeout(999) diff --git a/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodyMapperTest.java b/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodyMapperTest.java index dbccc71..82a8cfa 100644 --- a/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodyMapperTest.java +++ b/src/test/java/ee/bitweb/core/retrofit/logging/mappers/RetrofitResponseBodyMapperTest.java @@ -7,12 +7,12 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.util.HashMap; import java.util.HashSet; import java.util.Set; import java.util.zip.GZIPOutputStream; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; @Tag("unit") class RetrofitResponseBodyMapperTest { @@ -22,21 +22,13 @@ class RetrofitResponseBodyMapperTest { void isRedactUrl() { var mapper = new RetrofitResponseBodyMapper(Set.of("https://www.google.com/"), 0); - var response = new Response( - request("GET"), - Protocol.HTTP_1_0, - "message", - 200, - null, - new Headers.Builder().build(), - ResponseBody.create("123".getBytes(), MediaType.get("application/text")), - null, - null, - null, - 1, - 2, - null - ); + var response = new Response.Builder() + .request(request("GET")) + .protocol(Protocol.HTTP_1_0) + .message("message") + .code(200) + .body(ResponseBody.create("123".getBytes(), MediaType.get("application/text"))) + .build(); var value = mapper.getValue(null, response); @@ -48,21 +40,13 @@ void isRedactUrl() { void promisesBody() { var mapper = new RetrofitResponseBodyMapper(new HashSet<>(), 0); - var response = new Response( - request("HEAD"), - Protocol.HTTP_1_0, - "message", - 201, - null, - new Headers.Builder().build(), - ResponseBody.create("123".getBytes(), MediaType.get("application/text")), - null, - null, - null, - 1, - 2, - null - ); + var response = new Response.Builder() + .request(request("HEAD")) + .protocol(Protocol.HTTP_1_0) + .message("message") + .code(201) + .body(ResponseBody.create("123".getBytes(), MediaType.get("application/text"))) + .build(); var value = mapper.getValue(null, response); @@ -74,21 +58,14 @@ void promisesBody() { void bodyHasUnknownEncoding() { var mapper = new RetrofitResponseBodyMapper(new HashSet<>(), 0); - var response = new Response( - request("GET"), - Protocol.HTTP_1_0, - "message", - 201, - null, - new Headers.Builder().add("Content-Encoding", "unknownEncoding").build(), - ResponseBody.create("123".getBytes(), MediaType.get("application/text")), - null, - null, - null, - 1, - 2, - null - ); + var response = new Response.Builder() + .request(request("GET")) + .protocol(Protocol.HTTP_1_0) + .message("message") + .code(201) + .header("Content-Encoding", "unknownEncoding") + .body(ResponseBody.create("123".getBytes(), MediaType.get("application/text"))) + .build(); var value = mapper.getValue(null, response); @@ -100,21 +77,12 @@ void bodyHasUnknownEncoding() { void bodyMissing() { var mapper = new RetrofitResponseBodyMapper(new HashSet<>(), 0); - var response = new Response( - request("GET"), - Protocol.HTTP_1_0, - "message", - 201, - null, - new Headers.Builder().build(), - null, - null, - null, - null, - 1, - 2, - null - ); + // In OkHttp 5.x, Response.body() is non-null by design, so we need to mock it + var response = mock(Response.class); + when(response.request()).thenReturn(request("GET")); + when(response.code()).thenReturn(201); + when(response.headers()).thenReturn(new Headers.Builder().build()); + when(response.body()).thenReturn(null); var value = mapper.getValue(null, response); @@ -125,21 +93,13 @@ void bodyMissing() { @DisplayName("Response body is correctly returned") void bodyAvailable() { var mapper = new RetrofitResponseBodyMapper(new HashSet<>(), 4096); - var response = new Response( - request("GET"), - Protocol.HTTP_2, - "OK", - 200, - null, - new Headers.Builder().build(), - ResponseBody.create("123".getBytes(), MediaType.get("application/text")), - null, - null, - null, - 1, - 2, - null - ); + var response = new Response.Builder() + .request(request("GET")) + .protocol(Protocol.HTTP_2) + .message("OK") + .code(200) + .body(ResponseBody.create("123".getBytes(), MediaType.get("application/text"))) + .build(); assertEquals("123", mapper.getValue(null, response)); } @@ -148,21 +108,13 @@ void bodyAvailable() { @DisplayName("Response body is correctly shortened") void bodyIsShortened() { var mapper = new RetrofitResponseBodyMapper(new HashSet<>(), 2); - var response = new Response( - request("GET"), - Protocol.HTTP_2, - "OK", - 200, - null, - new Headers.Builder().build(), - ResponseBody.create("123".getBytes(), MediaType.get("application/text")), - null, - null, - null, - 1, - 2, - null - ); + var response = new Response.Builder() + .request(request("GET")) + .protocol(Protocol.HTTP_2) + .message("OK") + .code(200) + .body(ResponseBody.create("123".getBytes(), MediaType.get("application/text"))) + .build(); assertEquals("12 ... Content size: 3 characters", mapper.getValue(null, response)); } @@ -171,21 +123,13 @@ void bodyIsShortened() { @DisplayName("Response body is correctly returned when empty") void bodyIsEmpty() { var mapper = new RetrofitResponseBodyMapper(new HashSet<>(), 4096); - var response = new Response( - request("GET"), - Protocol.HTTP_2, - "OK", - 200, - null, - new Headers.Builder().build(), - ResponseBody.create(new byte[]{}, MediaType.get("application/text")), - null, - null, - null, - 1, - 2, - null - ); + var response = new Response.Builder() + .request(request("GET")) + .protocol(Protocol.HTTP_2) + .message("OK") + .code(200) + .body(ResponseBody.create(new byte[]{}, MediaType.get("application/text"))) + .build(); assertEquals("", mapper.getValue(null, response)); } @@ -194,21 +138,14 @@ void bodyIsEmpty() { @DisplayName("Response body is correctly returned when response is gzipped") void bodyIsGzipped() throws IOException { var mapper = new RetrofitResponseBodyMapper(new HashSet<>(), 4096); - var response = new Response( - request("GET"), - Protocol.HTTP_2, - "OK", - 200, - null, - new Headers.Builder().add("Content-Encoding", "gzip").build(), - ResponseBody.create(gzip("some amount of data"), MediaType.get("application/text")), - null, - null, - null, - 1, - 2, - null - ); + var response = new Response.Builder() + .request(request("GET")) + .protocol(Protocol.HTTP_2) + .message("OK") + .code(200) + .header("Content-Encoding", "gzip") + .body(ResponseBody.create(gzip("some amount of data"), MediaType.get("application/text"))) + .build(); assertEquals("some amount of data", mapper.getValue(null, response)); } @@ -220,16 +157,13 @@ void responseIsNull() { } private Request request(String method) { - return new Request( - new HttpUrl.Builder() + return new Request.Builder() + .url(new HttpUrl.Builder() .scheme("https") .host("www.google.com") - .build(), - method, - new Headers.Builder().build(), - RequestBody.create("123".getBytes(), MediaType.get("application/text")), - new HashMap<>() - ); + .build()) + .method(method, method.equals("GET") || method.equals("HEAD") ? null : RequestBody.create("123".getBytes(), MediaType.get("application/text"))) + .build(); } private byte[] gzip(String data) throws IOException { diff --git a/src/test/java/ee/bitweb/core/trace/AutoConfigurationTests.java b/src/test/java/ee/bitweb/core/trace/AutoConfigurationTests.java index 5536877..6bc7b5f 100644 --- a/src/test/java/ee/bitweb/core/trace/AutoConfigurationTests.java +++ b/src/test/java/ee/bitweb/core/trace/AutoConfigurationTests.java @@ -12,8 +12,10 @@ import ee.bitweb.core.trace.thread.ThreadTraceIdResolver; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.MDC; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; @@ -24,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.*; @Tag("integration") +@ExtendWith(MockitoExtension.class) @SpringBootTest( properties = { "ee.bitweb.core.trace.auto-configuration=true", diff --git a/src/test/java/ee/bitweb/core/trace/invoker/http/TraceIdFilterTest.java b/src/test/java/ee/bitweb/core/trace/invoker/http/TraceIdFilterTest.java index 1aede5d..3f587a5 100644 --- a/src/test/java/ee/bitweb/core/trace/invoker/http/TraceIdFilterTest.java +++ b/src/test/java/ee/bitweb/core/trace/invoker/http/TraceIdFilterTest.java @@ -1,11 +1,7 @@ package ee.bitweb.core.trace.invoker.http; -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.LoggerContext; import ee.bitweb.core.trace.context.MDCTraceIdContext; import ee.bitweb.core.trace.creator.TraceIdCreator; -import ee.bitweb.core.utils.MemoryAppender; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; @@ -15,7 +11,6 @@ import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; -import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; diff --git a/src/test/java/ee/bitweb/core/trace/thread/decorator/BasicMDCTaskDecoratorTest.java b/src/test/java/ee/bitweb/core/trace/thread/decorator/BasicMDCTaskDecoratorTest.java index 7d42f61..cdb3d99 100644 --- a/src/test/java/ee/bitweb/core/trace/thread/decorator/BasicMDCTaskDecoratorTest.java +++ b/src/test/java/ee/bitweb/core/trace/thread/decorator/BasicMDCTaskDecoratorTest.java @@ -5,8 +5,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.mockito.MockSettings; -import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.MDC; diff --git a/src/test/java/ee/bitweb/core/trace/thread/decorator/SecurityAwareMDCTaskDecoratorTest.java b/src/test/java/ee/bitweb/core/trace/thread/decorator/SecurityAwareMDCTaskDecoratorTest.java index 22120d6..53a3e64 100644 --- a/src/test/java/ee/bitweb/core/trace/thread/decorator/SecurityAwareMDCTaskDecoratorTest.java +++ b/src/test/java/ee/bitweb/core/trace/thread/decorator/SecurityAwareMDCTaskDecoratorTest.java @@ -9,7 +9,6 @@ import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.MDC; -import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import static org.junit.jupiter.api.Assertions.*; diff --git a/src/test/java/ee/bitweb/core/util/HttpForwardedHeaderParserTest.java b/src/test/java/ee/bitweb/core/util/HttpForwardedHeaderParserTest.java index 72827e7..3427121 100644 --- a/src/test/java/ee/bitweb/core/util/HttpForwardedHeaderParserTest.java +++ b/src/test/java/ee/bitweb/core/util/HttpForwardedHeaderParserTest.java @@ -53,7 +53,7 @@ void testParseThrowsWithNullString() { void testParseThrowsWithEmptyString() { InvalidArgumentException e = assertThrows( InvalidArgumentException.class, - () -> HttpForwardedHeaderParser.parse(((String) null)), + () -> HttpForwardedHeaderParser.parse(""), "expected InvalidArgumentException to be thrown" ); diff --git a/src/test/java/ee/bitweb/core/util/StringUtilTest.java b/src/test/java/ee/bitweb/core/util/StringUtilTest.java index b878faf..5dda3c8 100644 --- a/src/test/java/ee/bitweb/core/util/StringUtilTest.java +++ b/src/test/java/ee/bitweb/core/util/StringUtilTest.java @@ -1,20 +1,80 @@ package ee.bitweb.core.util; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; @Tag("unit") +@DisplayName("StringUtil") class StringUtilTest { - @Test - void random() { - // test multiple times to avoid possible IndexOutOfBounds exceptions - for (int i = 0; i < 10000; i++) { - assertEquals(10, StringUtil.random(10).length()); - assertEquals(30, StringUtil.random(30).length()); + @Nested + @DisplayName("trim()") + class Trim { + + @Test + @DisplayName("returns null for null input") + void returnsNullForNullInput() { + assertNull(StringUtil.trim(null)); + } + + @Test + @DisplayName("returns empty string for empty input") + void returnsEmptyStringForEmptyInput() { + assertEquals("", StringUtil.trim("")); + } + + @Test + @DisplayName("removes leading whitespace") + void removesLeadingWhitespace() { + assertEquals("hello", StringUtil.trim(" hello")); + assertEquals("hello", StringUtil.trim("\thello")); + assertEquals("hello", StringUtil.trim("\nhello")); + } + + @Test + @DisplayName("removes trailing whitespace") + void removesTrailingWhitespace() { + assertEquals("hello", StringUtil.trim("hello ")); + assertEquals("hello", StringUtil.trim("hello\t")); + assertEquals("hello", StringUtil.trim("hello\n")); + } + + @Test + @DisplayName("removes both leading and trailing whitespace") + void removesBothLeadingAndTrailingWhitespace() { + assertEquals("hello", StringUtil.trim(" hello ")); + assertEquals("hello world", StringUtil.trim(" hello world ")); + } + + @Test + @DisplayName("preserves internal whitespace") + void preservesInternalWhitespace() { + assertEquals("hello world", StringUtil.trim("hello world")); + assertEquals("hello world", StringUtil.trim(" hello world ")); + } + + @Test + @DisplayName("returns original when no whitespace to trim") + void returnsOriginalWhenNoWhitespace() { + assertEquals("hello", StringUtil.trim("hello")); } } + @Nested + @DisplayName("random()") + class Random { + + @Test + @DisplayName("generates string of specified length") + void generatesStringOfSpecifiedLength() { + for (int i = 0; i < 10000; i++) { + assertEquals(10, StringUtil.random(10).length()); + assertEquals(30, StringUtil.random(30).length()); + } + } + } } diff --git a/src/test/java/ee/bitweb/core/utils/MemoryAppender.java b/src/test/java/ee/bitweb/core/utils/MemoryAppender.java index cd975f8..499c428 100644 --- a/src/test/java/ee/bitweb/core/utils/MemoryAppender.java +++ b/src/test/java/ee/bitweb/core/utils/MemoryAppender.java @@ -2,7 +2,6 @@ import java.util.Collections; import java.util.List; -import java.util.stream.Collectors; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.spi.ILoggingEvent; @@ -28,14 +27,14 @@ public int countEventsForLogger(String loggerName) { public List search(String string) { return this.list.stream() .filter(event -> event.toString().contains(string)) - .collect(Collectors.toList()); + .toList(); } public List search(String string, Level level) { return this.list.stream() .filter(event -> event.toString().contains(string) && event.getLevel().equals(level)) - .collect(Collectors.toList()); + .toList(); } public int getSize() { diff --git a/test.gradle b/test.gradle index fe611ea..e3ddcd4 100644 --- a/test.gradle +++ b/test.gradle @@ -5,7 +5,10 @@ test { } tasks.register('unitTest', Test) { - group("verification") + group = "verification" + + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath useJUnitPlatform { excludeTags "integration" @@ -13,7 +16,10 @@ tasks.register('unitTest', Test) { } tasks.register('integrationTest', Test) { - group("verification") + group = "verification" + + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath useJUnitPlatform { includeTags 'integration' @@ -38,7 +44,7 @@ tasks.register('jacocoIntegrationTestReport', JacocoReport) { } } -tasks.register('testAndReport', Test) { +tasks.register('testAndReport') { tasks.getByName('integrationTest').mustRunAfter('unitTest') dependsOn('unitTest', 'integrationTest', 'jacocoUnitTestReport', 'jacocoIntegrationTestReport') }