diff --git a/.editorconfig b/.editorconfig index 4e27d6e..23ef410 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,5 +1,5 @@ -# EditorConfig is awesome: http://EditorConfig.org +# EditorConfig is awesome: https://EditorConfig.org # top-most EditorConfig file root = true @@ -57,10 +57,6 @@ ij_html_do_not_indent_children_of_tags = html indent_size = 4 ij_continuation_indent_size = 4 -# Go language uses tabs by default -[*.go] -indent_style = tab - # IntelliJ Kotlin specific rules [*.{kt,kts}] ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL diff --git a/.gitattributes b/.gitattributes index da6dca2..83746f6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,3 +12,4 @@ *.jpeg binary *.jar binary *.p12 binary +*.ttf binary diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e24ae8c..6d1e8b9 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -2,7 +2,8 @@ name: Nightly Build on: schedule: - - cron: "59 23 1 1 *" + - cron: "59 23 1 1 1" + # TODO Now running less than yearly. Restore to daily with commented line below # - cron: "59 23 * * *" permissions: read-all @@ -39,7 +40,7 @@ jobs: source "$HOME/.sdkman/bin/sdkman-init.sh" sdk env install - ./mvnw -P buildpack -P gatling + ./mvnw -P gatling - name: Build Client run: | export CLIENT_PATH='target/generated-sources/openapi' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52ff644..e1ab115 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,8 +34,8 @@ jobs: - name: Java Setup uses: actions/setup-java@v4 with: - java-version: '21' - distribution: temurin + java-version: '25' + distribution: liberica - name: Publish and Tag Application env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml new file mode 100644 index 0000000..0940ec4 --- /dev/null +++ b/.mvn/extensions.xml @@ -0,0 +1,8 @@ + + + + org.apache.maven.extensions + maven-build-cache-extension + 1.2.0 + + diff --git a/.mvn/maven.config b/.mvn/maven.config index 5838811..4921bde 100644 --- a/.mvn/maven.config +++ b/.mvn/maven.config @@ -1,3 +1,4 @@ -T1.5C --update-snapshots --fail-at-end +-D=mavenCentral diff --git a/.mvn/settings-example.xml b/.mvn/settings-example.xml new file mode 100644 index 0000000..a0dd093 --- /dev/null +++ b/.mvn/settings-example.xml @@ -0,0 +1,72 @@ + + + + + + + central + user + pass + + + + snapshots + user + pass + + + + + + + + artifactory + + + + !mavenCentral + + + + + + + false + + central + api-maven-release-virtual + https://artifactory.com/artifactory/api-maven-release-virtual + + + snapshots + api-maven-snapshot-virtual + https://artifactory.com/artifactory/api-maven-snapshot-virtual + + + + + + + false + + central + plugins-release + https://artifactory.com/artifactory/plugins-release + + + snapshots + plugins-release + https://artifactory.com/artifactory/plugins-release + + + + + diff --git a/.mvn/spring.xml b/.mvn/spring-boot.xml similarity index 85% rename from .mvn/spring.xml rename to .mvn/spring-boot.xml index 95cfd3b..5854036 100644 --- a/.mvn/spring.xml +++ b/.mvn/spring-boot.xml @@ -14,13 +14,13 @@ org.springframework.boot spring-boot-starter-parent - 3.4.5 + 4.0.0 com.github.jaguililla - spring - 0.1.0 + spring-boot + 1.0.0 pom Spring base POM @@ -34,7 +34,21 @@ - 3.9.9 + 3.2.0 + 3.7.1 + 3.5.0 + 3.14.1 + 3.6.2 + 3.2.8 + 3.1.4 + 3.4.2 + 3.12.0 + 3.2.0 + 3.3.1 + 3.21.0 + 3.3.1 + 3.5.4 + 2.0.0 false ${project.groupId}.${project.artifactId}.http @@ -42,35 +56,29 @@ ${openapi.package}.client ${project.groupId}/${project.artifactId} docker.io - undertow + jetty ui - 21 - 2.8.4 - 7.11.0 + 25 + 2.8.6 + 7.16.0 0.2.6 - 1.17.3 - 1.2.1 + 1.20.0 + 1.2.3 3.6.0 - 3.5.0 + 3.6.1 - 0.8.13 + 0.8.14 1.4.1 - 3.13.4 - 4.14.0 + 3.14.3 + 4.19.0 org.springframework.boot spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-tomcat - - org.springframework.boot @@ -110,12 +118,12 @@ org.testcontainers - postgresql + testcontainers-postgresql test org.testcontainers - kafka + testcontainers-kafka test @@ -130,6 +138,11 @@ ${gatling.version} test + + org.springframework.boot + spring-boot-restclient + test + @@ -137,11 +150,8 @@ - org.apache.maven.plugins maven-compiler-plugin - - --enable-preview - + ${maven-compiler-plugin.version} @@ -150,8 +160,13 @@ - org.apache.maven.plugins maven-failsafe-plugin + ${maven-failsafe-plugin.version} + + + + maven-surefire-plugin + ${maven-surefire-plugin.version} @@ -316,7 +331,6 @@ - org.apache.maven.plugins maven-checkstyle-plugin ${maven-checkstyle-plugin.version} @@ -338,7 +352,6 @@ - org.apache.maven.plugins maven-checkstyle-plugin ${maven-checkstyle-plugin.version} diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index d58dfb7..c0b0262 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,19 +1,3 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -wrapperVersion=3.3.2 +wrapperVersion=3.3.4 distributionType=only-script -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/4.0.0-rc-5/apache-maven-4.0.0-rc-5-bin.zip diff --git a/.sdkmanrc b/.sdkmanrc index deb1692..c249171 100644 --- a/.sdkmanrc +++ b/.sdkmanrc @@ -1,3 +1,6 @@ # Enable auto-env through the sdkman_auto_env config # Add key=value pairs of SDKs to use below -java=21.0.7-librca + +mvnd=2.0.0-rc-3 + +java=25.0.1.fx-librca diff --git a/Dockerfile b/Dockerfile index 67f60bb..6ee355d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -FROM docker.io/bellsoft/liberica-runtime-container:jre-21-slim-musl +FROM docker.io/bellsoft/liberica-runtime-container:jre-25-cds-slim-musl ARG PROJECT="appointments" ARG OTEL_BASE="github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download" @@ -14,8 +14,7 @@ USER 1000 ENV OTEL_SERVICE_NAME "$PROJECT" ENV PERFORMANCE_OPTIONS "-XX:+AlwaysPreTouch -XX:+UseParallelGC -XX:+UseNUMA" -ENV TELEMETRY_OPTIONS "-javaagent:./opentelemetry.jar" -ENV JAVA_TOOL_OPTIONS "$PERFORMANCE_OPTIONS $TELEMETRY_OPTIONS" +ENV JAVA_TOOL_OPTIONS "$PERFORMANCE_OPTIONS" HEALTHCHECK --interval=10s --start-period=10s CMD \ wget -O/dev/stdout --tries=1 \ diff --git a/README.md b/README.md index e98f744..6531d83 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Example application to create appointments (REST API). Appointments are stored i [Clean]: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html ## 🧰 Stack -* Java 21 -* Spring 3.3 (configurable server, 'undertow' by default) +* Java 25 +* Spring 4 (configurable server, 'jetty' by default) * Actuator (healthcheck, etc.) * Flyway (chosen over Liquibase for its simplicity) * Postgres @@ -51,7 +51,7 @@ Example application to create appointments (REST API). Appointments are stored i ## 📑 Requirements * Docker Compose -* JDK 21+ +* JDK 25+ * SDKMAN (optional, recommended) ## 🤔 Design Decisions @@ -142,3 +142,66 @@ To run the Gatling test, execute `./mvnw -P gatling` at the shell. * Merges to `main` create and publish a release * A release is a tag, a Maven package for the application, and another for the client * Publishing is uploading the release to GitHub packages + +# Security +REST API calls are secured by scopes provided in the authentication token. + +These are the available scopes: +* Write +* Read +* Admin + +The token should contain the principal. + +Test key store password: appointments + +# Security +* OpenID is used as the authentication flow +* Security logic is implemented at package `com.github.jaguililla.appointments.controllers` +* Actuator and Swagger paths are not protected +* The authenticator logic steps are: + * On each request, the OpenID configuration (Keycloak realm) is loaded from the JWT issuer + * Token scope is mapped to Spring Boot security model + * `@Secure` annotation is used to enforce a scope on an endpoint + +## Configuration +* For testing in a local environment the `http-server.dockerfile` container must be deployed + +## Test Resources +* A test key pair was generated for tests `src/test/resources/jwt/sign.{key,pub}.pem` +* JWKS configuration (`certs.json`) generated from `sign.pub.pem` +* Testing tokens generated with method: + `com.github.jaguililla.appointments.it.OpenIdMock.main` + +## Testing +* Run project's Docker Compose as described in the [README.md] file +* Start the application from the IDE +* Sample http requests for exploratory testing are available at + `src/test/resources/http/requests.sh` files +* On tests requests: OpenID Mock binding, token issuer and allowed issuers setting must match + +[README.md]: ../README.md + +# Domain Model +```mermaid +--- +title: Domain Model +--- +classDiagram + direction LR + + class Appointment { + id: int + start: LocalDateTime + end: LocalDateTime + users: List + } + + class User { + id: int + name: string + } +``` + +# Key +* appointments.p12 password: appointments diff --git a/docker-compose.yml b/docker-compose.yml index 5f2ea27..8f551ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,21 +4,21 @@ name: appointments services: postgres: - image: docker.io/postgres:17-alpine + image: docker.io/postgres:18-alpine platform: linux/amd64 environment: POSTGRES_USER: root POSTGRES_PASSWORD: root POSTGRES_DB: local - PGDATA: /var/lib/postgresql/data/pgdata +# PGDATA: /var/lib/postgresql/data/pgdata volumes: - - data:/var/lib/postgresql/data +# - data:/var/lib/postgresql/data - ./src/test/resources/db:/docker-entrypoint-initdb.d ports: - "127.0.0.1:15432:5432" kafka: - image: docker.io/apache/kafka-native:3.8.0 + image: docker.io/apache/kafka-native:4.1.0 platform: linux/amd64 environment: CLUSTER_ID: 4L6g3nShT-eMCtK--X86sw @@ -53,12 +53,20 @@ services: JDBC_USERNAME: root JDBC_PASSWORD: root KAFKA_SERVER: kafka:19092 + OPENID: http://openid.mock:12345/realms/appointments ports: - "127.0.0.1:18080:8080" openid.mock: - image: docker.io/bellsoft/liberica-runtime-container:jdk-21-slim-musl platform: linux/amd64 + build: + dockerfile: src/test/resources/http/http-server.dockerfile + environment: + OPENID_MOCK_CONTEXT: /realms/appointments + OPENID_MOCK_ROOT: /http + volumes: + - ./src/test/resources/jwt/openid-configuration.json:/http/.well-known/openid-configuration + - ./src/test/resources/jwt/certs.json:/http/protocol/openid-connect/certs security_opt: - no-new-privileges:true ports: diff --git a/mvnw b/mvnw index 19529dd..bd8896b 100755 --- a/mvnw +++ b/mvnw @@ -19,7 +19,7 @@ # ---------------------------------------------------------------------------- # ---------------------------------------------------------------------------- -# Apache Maven Wrapper startup batch script, version 3.3.2 +# Apache Maven Wrapper startup batch script, version 3.3.4 # # Optional ENV vars # ----------------- @@ -105,14 +105,17 @@ trim() { printf "%s" "${1}" | tr -d '[:space:]' } +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties while IFS="=" read -r key value; do case "${key-}" in distributionUrl) distributionUrl=$(trim "${value-}") ;; distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; esac -done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" -[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" case "${distributionUrl##*/}" in maven-mvnd-*bin.*) @@ -130,7 +133,7 @@ maven-mvnd-*bin.*) distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" ;; maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; -*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; esac # apply MVNW_REPOURL and calculate MAVEN_HOME @@ -227,7 +230,7 @@ if [ -n "${distributionSha256Sum-}" ]; then echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 exit 1 elif command -v sha256sum >/dev/null; then - if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then distributionSha256Result=true fi elif command -v shasum >/dev/null; then @@ -252,8 +255,41 @@ if command -v unzip >/dev/null; then else tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" fi -printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" -mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" clean || : exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd index 249bdf3..92450f9 100644 --- a/mvnw.cmd +++ b/mvnw.cmd @@ -19,7 +19,7 @@ @REM ---------------------------------------------------------------------------- @REM ---------------------------------------------------------------------------- -@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM Apache Maven Wrapper startup batch script, version 3.3.4 @REM @REM Optional ENV vars @REM MVNW_REPOURL - repo url base for downloading maven distribution @@ -40,7 +40,7 @@ @SET __MVNW_ARG0_NAME__= @SET MVNW_USERNAME= @SET MVNW_PASSWORD= -@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) @echo Cannot start maven from wrapper >&2 && exit /b 1 @GOTO :EOF : end batch / begin powershell #> @@ -73,16 +73,30 @@ switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { # apply MVNW_REPOURL and calculate MAVEN_HOME # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ if ($env:MVNW_REPOURL) { - $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } - $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" } $distributionUrlName = $distributionUrl -replace '^.*/','' $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' -$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" + +$MAVEN_M2_PATH = "$HOME/.m2" if ($env:MAVEN_USER_HOME) { - $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" } -$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { @@ -134,7 +148,33 @@ if ($distributionSha256Sum) { # unzip and move Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null -Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null try { Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null } catch { diff --git a/pom.xml b/pom.xml index b224af0..b357371 100644 --- a/pom.xml +++ b/pom.xml @@ -10,13 +10,13 @@ com.github.jaguililla - spring - 0.1.0 - .mvn/spring.xml + spring-boot + 1.0.0 + .mvn/spring-boot.xml appointments - 0.3.11 + 0.4.0 Appointments Application to create appointments (REST API) @@ -26,11 +26,12 @@ ${project.groupId}.${project.artifactId}.http ${openapi.package}.controllers ${openapi.package}.client - undertow + jetty ui com.github.jaguililla.appointments.GatlingSimulation + com.github.jaguililla.appointments.Application @@ -50,19 +51,8 @@ spring-boot-starter-oauth2-resource-server - io.micrometer - micrometer-registry-otlp - runtime - - - com.google.protobuf - protobuf-java - - - - - io.micrometer - micrometer-tracing-bridge-brave + org.springframework.boot + spring-boot-starter-flyway org.flywaydb @@ -73,8 +63,8 @@ postgresql - org.springframework.kafka - spring-kafka + org.springframework.boot + spring-boot-starter-kafka diff --git a/set_up.java b/set_up.java deleted file mode 100755 index 0e273de..0000000 --- a/set_up.java +++ /dev/null @@ -1,85 +0,0 @@ -///usr/bin/env java --enable-preview --source 21 -cp "*" "$0" "$@" ; exit $? - -import static java.util.Map.entry; -import static java.util.stream.Collectors.joining; - -import java.io.File; -import java.io.IOException; -import java.nio.file.FileSystem; -import java.nio.file.FileSystems; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; -import java.util.Map.Entry; -import java.util.stream.Stream; - -Scanner scanner = new Scanner(System.in); -FileSystem fs = FileSystems.getDefault(); - -void main() { - separator("--- DELETE COMPONENTS ---"); - - var deletions = - Stream.of( - entry(".github", prompt("Delete GitHub workflows and templates (yN): ", "n")), - entry(".gitlab*", prompt("Delete GitLab workflows and templates (yN): ", "n")), - entry("CODE_OF_CONDUCT.md", prompt("Delete 'CODE_OF_CONDUCT.md' file (yN): ", "n")), - entry("CONTRIBUTING.md", prompt("Delete 'CONTRIBUTING.md' file (yN): ", "n")), - entry("LICENSE.md", prompt("Delete 'LICENSE.md' file (yN): ", "n")), - entry(".git", prompt("Delete Git history (yN): ", "n")), - entry("set_up.java", prompt("Delete this set up file 'set_up.java' file (yN): ", "n")) - ) - .filter(it -> it.getValue().equalsIgnoreCase("y")) - .toList(); - - // TODO Rename organization, repository, artifact, group and base package (from most - // specific to less specific) - // To find out places to change, search for 'jaguililla' in the project - - separator("--- CONFIRM ---"); - - var deletionSummary = deletions.stream().map(Entry::getKey).collect(joining(", ")); - var confirm = prompt( - """ - Are you sure you want to: - * Delete the following files/directories: %s - * Rename %s to %s - """.formatted(deletionSummary, "a", "b"), - "n" - ); - - if (confirm.equalsIgnoreCase("n")) - System.exit(0); - - deletions.forEach(it -> delete(it.getKey())); -} - -void separator(String message) { - System.out.printf("\n%s\n\n", message); -} - -String prompt(String message, String defaultValue) { - System.out.print(message); - var v = scanner.nextLine(); - return v == null || v.isBlank() ? defaultValue : v; -} - -void delete(String glob) { - var matcher = fs.getPathMatcher("glob:./%s".formatted(glob)); - var cwd = new File("."); - Optional - .ofNullable(cwd.listFiles(it -> matcher.matches(it.toPath()))) - .map(List::of) - .orElse(List.of()) - .forEach(it -> { - try (Stream paths = Files.walk(it.toPath())) { - paths - .sorted(Comparator.reverseOrder()) - .map(Path::toFile) - .forEach(System.out::println); // (File::delete); - } - catch (IOException _) { - System.out.printf("Error deleting %s%n", it); - } - }); -} diff --git a/src/main/java/com/github/jaguililla/appointments/Application.java b/src/main/java/com/github/jaguililla/appointments/Application.java index 5b4b60e..03242e2 100644 --- a/src/main/java/com/github/jaguililla/appointments/Application.java +++ b/src/main/java/com/github/jaguililla/appointments/Application.java @@ -6,7 +6,7 @@ @SpringBootApplication class Application { - public static void main(String[] args) { + static void main(String... args) { SpringApplication.run(Application.class, args); } } diff --git a/src/main/java/com/github/jaguililla/appointments/controllers/package-info.java b/src/main/java/com/github/jaguililla/appointments/controllers/package-info.java index eba1f8c..1a39d42 100644 --- a/src/main/java/com/github/jaguililla/appointments/controllers/package-info.java +++ b/src/main/java/com/github/jaguililla/appointments/controllers/package-info.java @@ -1,5 +1,4 @@ -/** - * Contains the REST controllers of the application (driver adapter). Classes on this package cannot - * use any other application layer outside the domain. - */ + +/// Contains the REST controllers of the application (driver adapter). Classes on this package +/// cannot use any other application layer outside the domain. package com.github.jaguililla.appointments.controllers; diff --git a/src/main/java/com/github/jaguililla/appointments/domain/model/Appointment.java b/src/main/java/com/github/jaguililla/appointments/domain/model/Appointment.java index f6a144f..ae2a893 100644 --- a/src/main/java/com/github/jaguililla/appointments/domain/model/Appointment.java +++ b/src/main/java/com/github/jaguililla/appointments/domain/model/Appointment.java @@ -15,10 +15,10 @@ public record Appointment( List users ) { public Appointment { - requireNonNull(id, "id can not be null"); - requireNonNull(start, "start can not be null"); - requireNonNull(end, "end can not be null"); - requireNonNull(users, "users can not be null"); + requireNonNull(id, "id cannot be null"); + requireNonNull(start, "start cannot be null"); + requireNonNull(end, "end cannot be null"); + requireNonNull(users, "users cannot be null"); requireAfter(end, start, "end"); } diff --git a/src/main/java/com/github/jaguililla/appointments/domain/model/User.java b/src/main/java/com/github/jaguililla/appointments/domain/model/User.java index 5bdc21e..76bf76a 100644 --- a/src/main/java/com/github/jaguililla/appointments/domain/model/User.java +++ b/src/main/java/com/github/jaguililla/appointments/domain/model/User.java @@ -10,7 +10,7 @@ public record User( String name ) { public User { - requireNonNull(id, "id can not be null"); + requireNonNull(id, "id cannot be null"); requireNonBlank(name, "name"); } } diff --git a/src/main/java/com/github/jaguililla/appointments/domain/model/package-info.java b/src/main/java/com/github/jaguililla/appointments/domain/model/package-info.java index b9210ad..d3efe7b 100644 --- a/src/main/java/com/github/jaguililla/appointments/domain/model/package-info.java +++ b/src/main/java/com/github/jaguililla/appointments/domain/model/package-info.java @@ -1,5 +1,4 @@ -/** - * Holds the business entities. These are the data structures used by the business logic. Follows - * the same access rules as its parent package. - */ + +/// Holds the business entities. These are the data structures used by the business logic. Follows +/// the same access rules as its parent package. package com.github.jaguililla.appointments.domain.model; diff --git a/src/main/java/com/github/jaguililla/appointments/domain/package-info.java b/src/main/java/com/github/jaguililla/appointments/domain/package-info.java index faa59eb..a0ccf92 100644 --- a/src/main/java/com/github/jaguililla/appointments/domain/package-info.java +++ b/src/main/java/com/github/jaguililla/appointments/domain/package-info.java @@ -1,7 +1,6 @@ -/** - * Contains the business rules. Must not reference implementation details (storage, frameworks, - * etc.) directly, these features should be accessed by abstract interchangeable interfaces. - *

- * It's not a problem to reference this package from Controllers or Repositories. - */ + +/// Contains the business rules. Must not reference implementation details (storage, frameworks, +/// etc.) directly, these features should be accessed by abstract interchangeable interfaces. +/// +/// It's not a problem to reference this package from Controllers or Repositories. package com.github.jaguililla.appointments.domain; diff --git a/src/main/java/com/github/jaguililla/appointments/notifiers/KafkaTemplateAppointmentsNotifier.java b/src/main/java/com/github/jaguililla/appointments/notifiers/KafkaTemplateAppointmentsNotifier.java index b7434fe..58bf5fb 100644 --- a/src/main/java/com/github/jaguililla/appointments/notifiers/KafkaTemplateAppointmentsNotifier.java +++ b/src/main/java/com/github/jaguililla/appointments/notifiers/KafkaTemplateAppointmentsNotifier.java @@ -51,6 +51,7 @@ public void notify(final Event event, final Appointment appointment) { catch (InterruptedException | ExecutionException e) { var id = appointment.id(); var errorMessage = "Error sending notification for appointment: %s".formatted(id); + Thread.currentThread().interrupt(); throw new IllegalStateException(errorMessage, e); } } diff --git a/src/main/java/com/github/jaguililla/appointments/notifiers/package-info.java b/src/main/java/com/github/jaguililla/appointments/notifiers/package-info.java index dcfe809..d973112 100644 --- a/src/main/java/com/github/jaguililla/appointments/notifiers/package-info.java +++ b/src/main/java/com/github/jaguililla/appointments/notifiers/package-info.java @@ -1,6 +1,5 @@ -/** - * Contains Notifier port's actual implementations (adapters). Complex implementations may be - * moved to their own subpackage. These are implementation details and must not be used directly - * (except DI and tests). - */ + +/// Contains Notifier port's actual implementations (adapters). Complex implementations may be +/// moved to their own subpackage. These are implementation details and must not be used directly +/// (except DI and tests). package com.github.jaguililla.appointments.notifiers; diff --git a/src/main/java/com/github/jaguililla/appointments/package-info.java b/src/main/java/com/github/jaguililla/appointments/package-info.java index f49a550..3bedd8c 100644 --- a/src/main/java/com/github/jaguililla/appointments/package-info.java +++ b/src/main/java/com/github/jaguililla/appointments/package-info.java @@ -1,5 +1,4 @@ -/** - * Holds the Spring configuration (dependency injection) and contains the starting class for the - * application. - */ + +/// Holds the Spring configuration (dependency injection) and contains the starting class for the +/// application. package com.github.jaguililla.appointments; diff --git a/src/main/java/com/github/jaguililla/appointments/repositories/package-info.java b/src/main/java/com/github/jaguililla/appointments/repositories/package-info.java index 985326d..2f008f6 100644 --- a/src/main/java/com/github/jaguililla/appointments/repositories/package-info.java +++ b/src/main/java/com/github/jaguililla/appointments/repositories/package-info.java @@ -1,6 +1,5 @@ -/** - * Contains Repository ports' actual implementations (adapters). Complex implementations may be - * moved to their own subpackage. These are implementation details and must not be used directly - * (except DI and tests). - */ + +/// Contains Repository ports' actual implementations (adapters). Complex implementations may be +/// moved to their own subpackage. These are implementation details and must not be used directly +/// (except DI and tests). package com.github.jaguililla.appointments.repositories; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0961b99..f2e3d9d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -32,4 +32,4 @@ spring: auto-offset-reset: earliest security.oauth2.resourceserver.jwk: - issuer-uri: ${OPENID:http://localhost:9876/realms/appointments} + issuer-uri: ${OPENID:http://localhost:12345/realms/appointments} diff --git a/src/main/resources/static/simple.css b/src/main/resources/static/simple.css index b1a86ec..18eb82e 100644 --- a/src/main/resources/static/simple.css +++ b/src/main/resources/static/simple.css @@ -1,25 +1,12 @@ - /* Global variables. */ :root { - --sans-font: -apple-system, BlinkMacSystemFont, "Avenir Next", Avenir, "Nimbus Sans L", Roboto, - "Noto Sans", "Segoe UI", Arial, Helvetica, "Helvetica Neue", sans-serif; + /* Set sans-serif & mono fonts */ + --sans-font: -apple-system, BlinkMacSystemFont, "Avenir Next", Avenir, + "Nimbus Sans L", Roboto, "Noto Sans", "Segoe UI", Arial, Helvetica, + "Helvetica Neue", sans-serif; --mono-font: Consolas, Menlo, Monaco, "Andale Mono", "Ubuntu Mono", monospace; - - --small-font-size: 0.9rem; - - --body-font-size: 1.15rem; - --nav-font-size: 1rem; - --aside-font-size: 1rem; - --footer-font-size: var(--small-font-size); - --figcaption-font-size: var(--small-font-size); - --cite-font-size: var(--small-font-size); - - --nav-line-height: 2; - --body-line-height: 1.5; - --h123-line-height: 1.1; - --nav-mobile-line-height: 1; - --standard-border-radius: 5px; + --border-width: 1px; /* Default (light) theme */ --bg: #fff; @@ -40,7 +27,6 @@ @media (prefers-color-scheme: dark) { :root { color-scheme: dark; - --bg: #212121; --accent-bg: #2b2b2b; --text: #dcdcdc; @@ -52,7 +38,6 @@ --preformatted: #ccc; --disabled: #111; } - /* Add a bit of transparency so light media isn't so glaring in dark mode */ img, video { @@ -61,9 +46,7 @@ } /* Reset box-sizing */ -*, -*::before, -*::after { +*, *::before, *::after { box-sizing: border-box; } @@ -79,7 +62,7 @@ progress { html { /* Set the font globally */ - font-family: var(--sans-font), sans-serif; + font-family: var(--sans-font); scroll-behavior: smooth; } @@ -87,13 +70,12 @@ html { body { color: var(--text); background-color: var(--bg); - font-size: var(--body-font-size); - line-height: var(--body-line-height); + font-size: 1.15rem; + line-height: 1.5; display: grid; grid-template-columns: 1fr min(45rem, 90%) 1fr; margin: 0; } - body > * { grid-column: 2; } @@ -101,7 +83,7 @@ body > * { /* Make the header bg full width, but the content inline with body */ body > header { background-color: var(--accent-bg); - border-bottom: 1px solid var(--border); + border-bottom: var(--border-width) solid var(--border); text-align: center; padding: 0 0.5rem 2rem 0.5rem; grid-column: 1 / -1; @@ -121,7 +103,7 @@ body > header p { margin: 1rem auto; } -/* Add a little padding to ensure spacing is correct between content and header > nav */ +/* Add a little padding to ensure spacing is correct between content and header nav */ main { padding-top: 1.5rem; } @@ -130,9 +112,9 @@ body > footer { margin-top: 4rem; padding: 2rem 1rem 1.5rem 1rem; color: var(--text-light); - font-size: var(--footer-font-size); + font-size: 0.9rem; text-align: center; - border-top: 1px solid var(--border); + border-top: var(--border-width) solid var(--border); } /* Format headers */ @@ -167,13 +149,7 @@ p { } /* Prevent long strings from overflowing container */ -p, -h1, -h2, -h3, -h4, -h5, -h6 { +p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; } @@ -181,7 +157,7 @@ h6 { h1, h2, h3 { - line-height: var(--h123-line-height); + line-height: 1.1; } /* Reduce header size on mobile */ @@ -219,7 +195,7 @@ a.button, /* extra specificity to override a */ input[type="submit"], input[type="reset"], input[type="button"] { - border: 1px solid var(--accent); + border: var(--border-width) solid var(--accent); background-color: var(--accent); color: var(--accent-text); padding: 0.5rem 0.9rem; @@ -271,15 +247,15 @@ input:enabled:focus-visible:where( } /* Format navigation */ -header > nav { - font-size: var(--nav-font-size); - line-height: var(--nav-line-height); +header nav { + font-size: 1rem; + line-height: 2; padding: 1rem 0 0 0; } /* Use flexbox to allow items to wrap, as needed */ -header > nav ul, -header > nav ol { +header nav ul, +header nav ol { align-content: space-around; align-items: center; display: flex; @@ -292,15 +268,15 @@ header > nav ol { } /* List items are inline elements, make them behave more like blocks */ -header > nav ul li, -header > nav ol li { +header nav ul li, +header nav ol li { display: inline-block; } -header > nav a, -header > nav a:visited { +header nav a, +header nav a:visited { margin: 0 0.5rem 1rem 0.5rem; - border: 1px solid var(--border); + border: var(--border-width) solid var(--border); border-radius: var(--standard-border-radius); color: var(--text); display: inline-block; @@ -308,10 +284,11 @@ header > nav a:visited { text-decoration: none; } -header > nav a:hover, -header > nav a.current, -header > nav a[aria-current="page"], -header > nav a[aria-current="true"] { +header nav a:hover, +header nav a.current, +header nav a[aria-current="page"], +header nav a[aria-current="true"] { + background: var(--bg); border-color: var(--accent); color: var(--accent); cursor: pointer; @@ -319,33 +296,33 @@ header > nav a[aria-current="true"] { /* Reduce nav side on mobile */ @media only screen and (max-width: 720px) { - header > nav a { + header nav a { border: none; padding: 0; text-decoration: underline; - line-height: var(--nav-mobile-line-height); + line-height: 1; + } + + header nav a.current { + background: none; } } /* Consolidate box styling */ -aside, -details, -pre, -progress { +aside, details, pre, progress { background-color: var(--accent-bg); - border: 1px solid var(--border); + border: var(--border-width) solid var(--border); border-radius: var(--standard-border-radius); margin-bottom: 1rem; } aside { - font-size: var(--aside-font-size); + font-size: 1rem; width: 30%; padding: 0 15px; margin-inline-start: 15px; float: right; } - *[dir="rtl"] aside { float: left; } @@ -359,10 +336,8 @@ aside { } } -article, -fieldset, -dialog { - border: 1px solid var(--border); +article, fieldset, dialog { + border: var(--border-width) solid var(--border); padding: 1rem; border-radius: var(--standard-border-radius); margin-bottom: 1rem; @@ -376,8 +351,8 @@ section h3:first-child { } section { - border-top: 1px solid var(--border); - border-bottom: 1px solid var(--border); + border-top: var(--border-width) solid var(--border); + border-bottom: var(--border-width) solid var(--border); padding: 2rem 1rem; margin: 3rem 0; } @@ -435,7 +410,7 @@ figure > table { td, th { - border: 1px solid var(--border); + border: var(--border-width) solid var(--border); text-align: start; padding: 0.5rem; } @@ -463,26 +438,23 @@ button, .button { font-size: inherit; font-family: inherit; - padding: 0.5rem; + padding: 0.5em; margin-bottom: 0.5rem; border-radius: var(--standard-border-radius); box-shadow: none; max-width: 100%; display: inline-block; } - textarea, select, input { color: var(--text); background-color: var(--bg); - border: 1px solid var(--border); + border: var(--border-width) solid var(--border); } - label { display: block; } - textarea:not([cols]) { width: 100%; } @@ -490,13 +462,12 @@ textarea:not([cols]) { /* Add arrow to drop-down */ select:not([multiple]) { background-image: linear-gradient(45deg, transparent 49%, var(--text) 51%), - linear-gradient(135deg, var(--text) 51%, transparent 49%); + linear-gradient(135deg, var(--text) 51%, transparent 49%); background-position: calc(100% - 15px), calc(100% - 10px); background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; padding-inline-end: 25px; } - *[dir="rtl"] select:not([multiple]) { background-position: 10px, 15px; } @@ -526,30 +497,29 @@ input[type="radio"]:checked { input[type="checkbox"]:checked::after { /* Creates a rectangle with colored right and bottom borders which is rotated to look like a check mark */ content: " "; - width: 0.18em; - height: 0.32em; + width: 0.2em; + height: 0.4em; border-radius: 0; position: absolute; - top: 0.05em; - left: 0.17em; + top: 0.04em; + left: 0.18em; background-color: transparent; border-right: solid var(--bg) 0.08em; border-bottom: solid var(--bg) 0.08em; font-size: 1.8em; transform: rotate(45deg); } - input[type="radio"]:checked::after { /* creates a colored circle for the checked radio button */ content: " "; - width: 0.25em; - height: 0.25em; + width: 0.3em; + height: 0.3em; border-radius: 100%; position: absolute; top: 0.125em; background-color: var(--bg); left: 0.125em; - font-size: 32px; + font-size: 1.8em; } /* Makes input fields wider on smaller screens */ @@ -564,7 +534,7 @@ input[type="radio"]:checked::after { /* Set a height for color input */ input[type="color"] { height: 2.5rem; - padding: 0.2rem; + padding: 0.2rem; } /* do not show border around file selector button */ @@ -575,7 +545,7 @@ input[type="file"] { /* Misc body elements */ hr { border: none; - height: 1px; + height: var(--border-width); background: var(--border); margin: 1rem auto; } @@ -611,8 +581,10 @@ figure > picture > img { } figcaption { + position: sticky; + left: 0; text-align: center; - font-size: var(--figcaption-font-size); + font-size: 0.9rem; color: var(--text-light); margin-block: 1rem; } @@ -628,13 +600,13 @@ blockquote { } cite { - font-size: var(--cite-font-size); + font-size: 0.9rem; color: var(--text-light); font-style: normal; } dt { - color: var(--text-light); + color: var(--text-light); } /* Use mono font for code elements */ @@ -643,13 +615,13 @@ pre, pre span, kbd, samp { - font-family: var(--mono-font), monospace; + font-family: var(--mono-font); color: var(--code); } kbd { color: var(--preformatted); - border: 1px solid var(--preformatted); + border: var(--border-width) solid var(--preformatted); border-bottom: 3px solid var(--preformatted); border-radius: var(--standard-border-radius); padding: 0.1rem 0.4rem; @@ -715,15 +687,13 @@ dialog::backdrop { @media only screen and (max-width: 720px) { dialog { - max-width: 100%; - margin: auto 1em; + max-width: calc(100vw - 2rem); } } /* Superscript & Subscript */ /* Prevent scripts from affecting line-height. */ -sup, -sub { +sup, sub { vertical-align: baseline; position: relative; } @@ -739,7 +709,7 @@ sub { /* Classes for notices */ .notice { background: var(--accent-bg); - border: 2px solid var(--border); + border: var(--border-width) solid var(--border); border-radius: var(--standard-border-radius); padding: 1.5rem; margin: 2rem 0; @@ -778,10 +748,10 @@ sub { orphans: 3; } hr { - border-top: 1px solid var(--border); + border-top: var(--border-width) solid var(--border); } mark { - border: 1px solid var(--border); + border: var(--border-width) solid var(--border); } pre, table, figure, img, svg { break-inside: avoid; diff --git a/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java b/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java index 1d3f35a..2df3f73 100644 --- a/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java +++ b/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java @@ -23,7 +23,7 @@ import org.springframework.kafka.core.ConsumerFactory; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.postgresql.PostgreSQLContainer; import org.testcontainers.kafka.KafkaContainer; import java.time.LocalDateTime; import java.util.Arrays; @@ -38,8 +38,8 @@ class ApplicationIT { static final OpenIdMock OPENID_MOCK = new OpenIdMock(); - static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine"); - static KafkaContainer kafka = new KafkaContainer("apache/kafka:3.8.0"); + static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:18-alpine"); + static KafkaContainer kafka = new KafkaContainer("apache/kafka-native:4.1.0"); private final TestTemplate client; @Autowired diff --git a/src/test/java/com/github/jaguililla/appointments/Asserts.java b/src/test/java/com/github/jaguililla/appointments/Asserts.java index 89312a1..25badaf 100644 --- a/src/test/java/com/github/jaguililla/appointments/Asserts.java +++ b/src/test/java/com/github/jaguililla/appointments/Asserts.java @@ -12,7 +12,7 @@ static void assertIllegalArgument(String message, Executable executable) { } static void assertNull(String field, Executable executable) { - assertThrows(NullPointerException.class, "%s can not be null".formatted(field), executable); + assertThrows(NullPointerException.class, "%s cannot be null".formatted(field), executable); } static void assertThrows( diff --git a/src/test/java/com/github/jaguililla/appointments/TestTemplate.java b/src/test/java/com/github/jaguililla/appointments/TestTemplate.java index f621dd9..4354b74 100644 --- a/src/test/java/com/github/jaguililla/appointments/TestTemplate.java +++ b/src/test/java/com/github/jaguililla/appointments/TestTemplate.java @@ -9,7 +9,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.github.jaguililla.appointments.it.JwtTokenManager; import org.slf4j.Logger; -import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.boot.restclient.RestTemplateBuilder; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatusCode; @@ -89,8 +89,7 @@ public T getResponseBody(final Class type) { return mapper.readValue(body, type); } catch (final JsonProcessingException e) { - final var message = "Error mapping response body to '%s':\n%s" - .formatted(type.getName(), body); + final var message = "Error mapping response body to '" + type.getName() + "':\n" + body; throw new RuntimeException(message, e); } } @@ -103,7 +102,7 @@ private RequestEntity createRequest(HttpMethod method, String path, Obje final var headers = new HttpHeaders(); final var uri = URI.create(rootUri + path); final var request = new RequestEntity<>(body, headers, method, uri); - final var token = tokenManager.createToken("http://localhost:9876/realms/appointments"); + final var token = tokenManager.createToken("scope", "http://localhost:12345/realms/appointments"); headers.setBearerAuth(token); return request; diff --git a/src/test/java/com/github/jaguililla/appointments/it/JwtTokenManager.java b/src/test/java/com/github/jaguililla/appointments/it/JwtTokenManager.java index 6589018..c96b1e4 100644 --- a/src/test/java/com/github/jaguililla/appointments/it/JwtTokenManager.java +++ b/src/test/java/com/github/jaguililla/appointments/it/JwtTokenManager.java @@ -2,6 +2,9 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.ClassPathResource; @@ -20,8 +23,9 @@ import java.util.Map; import java.util.UUID; -import static io.undertow.util.Headers.REALM; +import static com.github.jaguililla.appointments.it.OpenIdMock.PORT; import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Arrays.stream; public class JwtTokenManager { @@ -29,19 +33,23 @@ public class JwtTokenManager { private static final Base64.Decoder BASE64_DECODER = Base64.getMimeDecoder(); private final Algorithm ALGORITHM; + private final ObjectMapper objectMapper; + private final ObjectWriter objectWriter; public JwtTokenManager() { try { RSAPrivateKey privateKey = readRsaPrivateKey("jwt/sign.key.pem"); RSAPublicKey publicKey = readRsaPublicKey("jwt/sign.pub.pem"); ALGORITHM = Algorithm.RSA256(publicKey, privateKey); + objectMapper = new ObjectMapper().findAndRegisterModules(); + objectWriter = objectMapper.writer().withDefaultPrettyPrinter(); } catch (IOException | InvalidKeySpecException | NoSuchAlgorithmException e) { throw new RuntimeException(e); } } - public String createToken(String issuer) { + public String createToken(String scope, String issuer) { var token = JWT .create() .withExpiresAt(Instant.now().plusSeconds(3600 * 24 * 365)) @@ -49,17 +57,61 @@ public String createToken(String issuer) { .withJWTId(UUID.randomUUID().toString()) .withIssuer(issuer) .withAudience("account") - .withSubject("subject") + .withSubject("17dafd6a-ecde-48e6-989b-81857ef1089e") .withClaim("typ", "Bearer") .withClaim("allowed-origins", List.of("/*")) + .withClaim("scope", scope) .sign(ALGORITHM); - LOGGER.info("ISSUER: {}\n{}", issuer, token); + LOGGER.info("SCOPE: {} ISSUER: {}\n{}", scope, issuer, token); return token; } - private String createToken(String server, int port) { - return createToken("%s:%d%s".formatted(server, port, REALM)); + private String createToken(String scope, String server, int port) { + return createToken(scope, "%s:%d".formatted(server, port)); + } + + String createToken(String scope) { + return createToken(scope, "http://localhost", PORT); + } + + List> decodeToken(String token) { + var type = new TypeReference>() { + }; + + return stream(token.split("\\.")) + .toList() + .subList(0, 2) + .stream() + .map(it -> { + try { + var text = new String(JwtTokenManager.BASE64_DECODER.decode(it)); + return objectMapper.readValue(text, type); + } + catch (IOException e) { + throw new RuntimeException(e); + } + }) + .toList(); + } + + List> decodeResource(String resource) throws IOException { + var token = new ClassPathResource(resource).getContentAsString(UTF_8); + return decodeToken(token); + } + + void printToken(List> token) { + token + .stream() + .map(it -> { + try { + return objectWriter.writeValueAsString(it); + } + catch (IOException e) { + throw new RuntimeException(e); + } + }) + .forEach(System.out::println); } private static RSAPrivateKey readRsaPrivateKey(String resource) @@ -88,14 +140,14 @@ private static byte[] readPem(String resource) throws IOException { } /** - * Create test tokens. + * Create test tokens for development environments. */ public static void main(String... args) { var jwtTokenManager = new JwtTokenManager(); Map.of( - "http://openid.mock", 9876, - "http://localhost", 9876 + "http://openid.mock", 12345, + "http://localhost", 12345 ) - .forEach((endpoint, port) -> jwtTokenManager.createToken(endpoint, port)); + .forEach((endpoint, port) -> jwtTokenManager.createToken("TODO", endpoint, port)); } } diff --git a/src/test/java/com/github/jaguililla/appointments/it/OpenIdMock.java b/src/test/java/com/github/jaguililla/appointments/it/OpenIdMock.java index 11c052e..b3b61ce 100644 --- a/src/test/java/com/github/jaguililla/appointments/it/OpenIdMock.java +++ b/src/test/java/com/github/jaguililla/appointments/it/OpenIdMock.java @@ -17,7 +17,7 @@ public class OpenIdMock { private static final String JSON = "application/json; charset=" + UTF_8.name(); static final String CONTEXT = "/realms/appointments"; - static final int PORT = 9876; + static final int PORT = 12345; private HttpServer httpServer; diff --git a/src/test/java/httpServer.java b/src/test/java/httpServer.java index 0683e60..bc4d014 100755 --- a/src/test/java/httpServer.java +++ b/src/test/java/httpServer.java @@ -1,44 +1,39 @@ import com.sun.net.httpserver.HttpServer; -import java.net.InetSocketAddress; -import java.nio.file.Files; -import java.nio.file.Path; - import static java.net.HttpURLConnection.HTTP_OK; import static java.nio.charset.StandardCharsets.UTF_8; -class httpServer { - private static final String JSON = "application/json; charset=" + UTF_8.name(); +private static final String JSON = "application/json; charset=" + UTF_8.name(); - public static void main(String... args) throws Exception { - var mockPort = System.getenv("OPENID_MOCK_PORT"); - var mockContext = System.getenv("OPENID_MOCK_CONTEXT"); - var mockRoot = System.getenv("OPENID_MOCK_ROOT"); +void main() throws Exception { + var mockPort = System.getenv("OPENID_MOCK_PORT"); + var mockContext = System.getenv("OPENID_MOCK_CONTEXT"); + var mockRoot = System.getenv("OPENID_MOCK_ROOT"); - var port = Integer.parseInt(mockPort == null ? "12345" : mockPort); - var context = mockContext == null ? "/context" : mockContext; - var root = mockRoot == null ? "src/test/resources" : mockRoot; + var port = Integer.parseInt(mockPort == null ? "12345" : mockPort); + var context = mockContext == null ? "/context" : mockContext; + var root = mockRoot == null ? "src/test/resources" : mockRoot; - var rootPath = Path.of(root); - var httpServer = HttpServer.create(new InetSocketAddress(port), 0); + var rootPath = Path.of(root); + var httpServer = HttpServer.create(new InetSocketAddress(port), 0); - httpServer.createContext(context, exchange -> { - var uriPath = exchange.getRequestURI().getPath(); - var path = uriPath.replaceFirst("^" + context + "/?", ""); - var host = exchange.getRequestHeaders().getFirst("Host"); + httpServer.createContext(context, exchange -> { + var uriPath = exchange.getRequestURI().getPath(); + var path = uriPath.replaceFirst("^" + context + "/?", ""); + var host = exchange.getRequestHeaders().getFirst("Host"); + var binding = "http://" + host + context; - var responsePath = rootPath.resolve(path); - var responseTemplate = Files.readAllBytes(responsePath); - var responseText = new String(responseTemplate).replaceAll("", host); - var response = responseText.getBytes(); + var responsePath = rootPath.resolve(path); + var responseTemplate = Files.readAllBytes(responsePath); + var responseText = new String(responseTemplate).replaceAll("", binding); + var response = responseText.getBytes(); - exchange.getResponseHeaders().set("Content-Type", JSON); - exchange.sendResponseHeaders(HTTP_OK, response.length); - exchange.getResponseBody().write(response); - exchange.close(); - }); + exchange.getResponseHeaders().set("Content-Type", JSON); + exchange.sendResponseHeaders(HTTP_OK, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); - httpServer.start(); - System.err.println("Running on: http://localhost:" + port); - } + httpServer.start(); + System.err.println("Running on: http://localhost:" + port); } diff --git a/src/test/resources/http/http-server.dockerfile b/src/test/resources/http/http-server.dockerfile index a50bcbd..e3ded7c 100644 --- a/src/test/resources/http/http-server.dockerfile +++ b/src/test/resources/http/http-server.dockerfile @@ -1,13 +1,10 @@ -FROM docker.io/bellsoft/liberica-runtime-container:jdk-21-slim-musl +FROM docker.io/bellsoft/liberica-runtime-container:jdk-25-slim-musl USER 1000 -# TODO Mount these as volumes -#COPY src/test/resources/jwt/openid-configuration.json /configuration.json -#COPY src/test/resources/jwt/certs.json /certs.json COPY src/test/java/httpServer.java /httpServer.java HEALTHCHECK --interval=10s CMD \ - wget -O/dev/stdout --tries=1 http://localhost:9876 2>/dev/null | grep index || exit 1 + wget -O/dev/stdout --tries=1 http://localhost:12345 2>/dev/null | grep index || exit 1 ENTRYPOINT [ "java", "/httpServer.java" ] diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties new file mode 100644 index 0000000..b09270f --- /dev/null +++ b/src/test/resources/junit-platform.properties @@ -0,0 +1,2 @@ + +junit.jupiter.testclass.order.default=org.junit.jupiter.api.ClassOrderer$OrderAnnotation