diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..abf14b0
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,5 @@
+
+*
+!target/*.jar
+!src/test/resources/jwt
+!src/test/java/httpServer.java
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..da6dca2
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,14 @@
+
+# Declare files that will always have LF line endings on checkout
+* text eol=lf
+
+# Windows CMD does not like LF in batch files
+*.bat text eol=crlf
+*.cmd text eol=crlf
+
+# Files that are truly binary and should not be modified
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.jar binary
+*.p12 binary
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index c5e2beb..f28225d 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -16,7 +16,7 @@ variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ''
DOCKER_DRIVER: overlay2
- MAVEN_OPTS: -Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository
+ MAVEN_OPTS: -D maven.repo.local=$CI_PROJECT_DIR/.m2/repository
build:
stage: build
diff --git a/.mvn/parent.xml b/.mvn/spring.xml
similarity index 90%
rename from .mvn/parent.xml
rename to .mvn/spring.xml
index 70b41c0..95cfd3b 100644
--- a/.mvn/parent.xml
+++ b/.mvn/spring.xml
@@ -14,20 +14,28 @@
org.springframework.boot
spring-boot-starter-parent
- 3.4.3
+ 3.4.5
com.github.jaguililla
- parent
+ spring
0.1.0
pom
- Parent
- Common Maven settings (excluding DB and messaging)
+ Spring base POM
+
+ Common Spring settings (excluding DB and messaging):
+
+ * Default configurations (i.e.: encoding)
+ * Set fix stable versions (Java, Maven, etc.)
+ * OpenAPI code generation (server and client)
+ * Profiles for Gatling load tests, mutation tests, Docker image generation, etc.
+
3.9.9
+
false
${project.groupId}.${project.artifactId}.http
${openapi.package}.controllers
@@ -41,8 +49,14 @@
2.8.4
7.11.0
- 0.8.12
- 1.4.0
+ 0.2.6
+ 1.17.3
+ 1.2.1
+ 3.6.0
+ 3.5.0
+
+ 0.8.13
+ 1.4.1
3.13.4
4.14.0
@@ -104,6 +118,12 @@
kafka
test
+
+ com.auth0
+ java-jwt
+ 4.4.0
+ test
+
io.gatling.highcharts
gatling-charts-highcharts
@@ -238,8 +258,7 @@
org.openapitools
jackson-databind-nullable
-
- 0.2.6
+ ${jackson-databind-nullable.version}
@@ -256,14 +275,12 @@
org.pitest
pitest-maven
-
- 1.17.3
+ ${pitest-maven.version}
org.pitest
pitest-junit5-plugin
-
- 1.2.1
+ ${pitest-junit5-plugin.version}
@@ -301,8 +318,7 @@
org.apache.maven.plugins
maven-checkstyle-plugin
-
- 3.6.0
+ ${maven-checkstyle-plugin.version}
true
@@ -324,8 +340,7 @@
org.apache.maven.plugins
maven-checkstyle-plugin
-
- 3.6.0
+ ${maven-checkstyle-plugin.version}
@@ -348,8 +363,7 @@
org.codehaus.mojo
exec-maven-plugin
-
- 3.5.0
+ ${exec-maven-plugin.version}
docker-up
diff --git a/.sdkmanrc b/.sdkmanrc
index 774c30d..deb1692 100644
--- a/.sdkmanrc
+++ b/.sdkmanrc
@@ -1,3 +1,3 @@
# Enable auto-env through the sdkman_auto_env config
# Add key=value pairs of SDKs to use below
-java=21.0.6-librca
+java=21.0.7-librca
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..67f60bb
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,24 @@
+
+FROM docker.io/bellsoft/liberica-runtime-container:jre-21-slim-musl
+
+ARG PROJECT="appointments"
+ARG OTEL_BASE="github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download"
+ARG OTEL_VERSION="v1.32.1"
+
+WORKDIR /opt/$PROJECT
+
+COPY target/$PROJECT-*.jar application.jar
+ADD https://$OTEL_BASE/$OTEL_VERSION/opentelemetry-javaagent.jar opentelemetry.jar
+
+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"
+
+HEALTHCHECK --interval=10s --start-period=10s CMD \
+ wget -O/dev/stdout --tries=1 \
+ http://localhost:8080/actuator/health 2>/dev/null | grep UP || exit 1
+
+ENTRYPOINT [ "java", "-jar", "application.jar" ]
diff --git a/README.md b/README.md
index 97fef08..e98f744 100644
--- a/README.md
+++ b/README.md
@@ -137,3 +137,8 @@ To run the Gatling test, execute `./mvnw -P gatling` at the shell.
[Gatling settings]: https://docs.gatling.io/reference/script/core/configuration
[gatlingDefaults]: https://github.com/gatling/gatling/blob/main/gatling-core/src/main/resources/gatling-defaults.conf
+
+## Release
+* 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
diff --git a/docker-compose.yml b/docker-compose.yml
index ff8645a..5f2ea27 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,11 +4,13 @@ name: appointments
services:
postgres:
- image: docker.io/postgres:16-alpine
+ image: docker.io/postgres:17-alpine
+ platform: linux/amd64
environment:
POSTGRES_USER: root
POSTGRES_PASSWORD: root
POSTGRES_DB: local
+ PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- data:/var/lib/postgresql/data
- ./src/test/resources/db:/docker-entrypoint-initdb.d
@@ -17,6 +19,7 @@ services:
kafka:
image: docker.io/apache/kafka-native:3.8.0
+ platform: linux/amd64
environment:
CLUSTER_ID: 4L6g3nShT-eMCtK--X86sw
KAFKA_NODE_ID: 1
@@ -35,22 +38,14 @@ services:
ports:
- "127.0.0.1:9092:9092"
-# openobserve:
-# image: docker.io/openobserve/openobserve:v0.14.0
-# environment:
-# ZO_ROOT_USER_EMAIL: "root@example.com"
-# ZO_ROOT_USER_PASSWORD: "Complex-pass#123"
-# volumes:
-# - data:/data
-# ports:
-# - "5080:5080"
-
appointments:
- profiles: [ local ]
depends_on:
- postgres
- kafka
image: docker.io/com.github.jaguililla/appointments
+ platform: linux/amd64
+ build:
+ context: .
environment:
GLOBAL_LOG_LEVEL: warn
APPLICATION_LOG_LEVEL: info
@@ -61,6 +56,14 @@ services:
ports:
- "127.0.0.1:18080:8080"
+ openid.mock:
+ image: docker.io/bellsoft/liberica-runtime-container:jdk-21-slim-musl
+ platform: linux/amd64
+ security_opt:
+ - no-new-privileges:true
+ ports:
+ - "127.0.0.1:12345:12345"
+
volumes:
data:
driver: local
diff --git a/pom.xml b/pom.xml
index ae62d3b..b224af0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,13 +10,13 @@
com.github.jaguililla
- parent
+ spring
0.1.0
- .mvn/parent.xml
+ .mvn/spring.xml
appointments
- 0.3.10
+ 0.3.11
Appointments
Application to create appointments (REST API)
@@ -45,11 +45,25 @@
org.springframework.boot
spring-boot-starter-jdbc
-
-
-
-
-
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-resource-server
+
+
+ io.micrometer
+ micrometer-registry-otlp
+ runtime
+
+
+ com.google.protobuf
+ protobuf-java
+
+
+
+
+ io.micrometer
+ micrometer-tracing-bridge-brave
+
org.flywaydb
flyway-database-postgresql
diff --git a/src/main/java/com/github/jaguililla/appointments/ApplicationConfiguration.java b/src/main/java/com/github/jaguililla/appointments/ApplicationConfiguration.java
index c45f9f7..d577c0c 100644
--- a/src/main/java/com/github/jaguililla/appointments/ApplicationConfiguration.java
+++ b/src/main/java/com/github/jaguililla/appointments/ApplicationConfiguration.java
@@ -25,9 +25,13 @@ class ApplicationConfiguration {
private String deleteMessage;
@Bean
- AppointmentsNotifier appointmentsNotifier(final KafkaTemplate kafkaTemplate) {
+ AppointmentsNotifier appointmentsNotifier(
+ final KafkaTemplate kafkaTemplate,
+ final ConsumerFactory consumerFactory
+ ) {
final var type = KafkaTemplateAppointmentsNotifier.class.getSimpleName();
LOGGER.info("Creating Appointments Notifier: {}", type);
+ kafkaTemplate.setConsumerFactory(consumerFactory);
return new KafkaTemplateAppointmentsNotifier(
kafkaTemplate, notifierTopic, createMessage, deleteMessage
);
diff --git a/src/main/java/com/github/jaguililla/appointments/controllers/SecurityConfiguration.java b/src/main/java/com/github/jaguililla/appointments/controllers/SecurityConfiguration.java
new file mode 100644
index 0000000..3c560bf
--- /dev/null
+++ b/src/main/java/com/github/jaguililla/appointments/controllers/SecurityConfiguration.java
@@ -0,0 +1,52 @@
+package com.github.jaguililla.appointments.controllers;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig;
+import org.springframework.security.web.SecurityFilterChain;
+
+import static org.springframework.security.oauth2.jwt.JwtDecoders.fromIssuerLocation;
+
+@Configuration
+@EnableWebSecurity
+@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)
+public class SecurityConfiguration {
+
+ private static final String[] PERMITTED_ENDPOINTS = {
+ "/actuator/info",
+ "/actuator/info/**",
+ "/actuator/health",
+ "/actuator/health/**",
+ "/actuator/metrics",
+ "/actuator/metrics/**",
+ "/v*/api-docs/**",
+ "/v*/api-docs*",
+ "/swagger-ui.html",
+ "/swagger-ui/**"
+ };
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(
+ HttpSecurity http,
+ // TODO There should be a way to inject the JwtDecoder (this is a Spring property)
+ @Value("${spring.security.oauth2.resourceserver.jwk.issuer-uri}") String issuer
+ ) throws Exception {
+
+ var jwtDecoder = fromIssuerLocation(issuer);
+
+ return http
+ .csrf(AbstractHttpConfigurer::disable)
+ .headers(headers -> headers.frameOptions(FrameOptionsConfig::sameOrigin))
+ .authorizeHttpRequests(requests -> requests
+ .requestMatchers(PERMITTED_ENDPOINTS).permitAll()
+ .anyRequest().authenticated()
+ )
+ .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(jwtDecoder)))
+ .build();
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index ae9ee53..0961b99 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -30,3 +30,6 @@ spring:
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
group-id: tests
auto-offset-reset: earliest
+
+ security.oauth2.resourceserver.jwk:
+ issuer-uri: ${OPENID:http://localhost:9876/realms/appointments}
diff --git a/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java b/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java
index df77bad..1d3f35a 100644
--- a/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java
+++ b/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java
@@ -8,6 +8,8 @@
import com.github.jaguililla.appointments.http.controllers.messages.AppointmentResponse;
import java.time.Duration;
import java.util.List;
+
+import com.github.jaguililla.appointments.it.OpenIdMock;
import org.apache.kafka.common.TopicPartition;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
@@ -34,6 +36,8 @@
@TestMethodOrder(OrderAnnotation.class)
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");
@@ -49,11 +53,13 @@ class ApplicationIT {
static void beforeAll() {
postgres.start();
kafka.start();
+ OPENID_MOCK.start();
}
@AfterAll
static void afterAll() {
postgres.stop();
+ OPENID_MOCK.stop();
}
@DynamicPropertySource
diff --git a/src/test/java/com/github/jaguililla/appointments/TestTemplate.java b/src/test/java/com/github/jaguililla/appointments/TestTemplate.java
index 4c4c1e3..f621dd9 100644
--- a/src/test/java/com/github/jaguililla/appointments/TestTemplate.java
+++ b/src/test/java/com/github/jaguililla/appointments/TestTemplate.java
@@ -1,18 +1,25 @@
package com.github.jaguililla.appointments;
import static org.slf4j.LoggerFactory.getLogger;
+import static org.springframework.http.HttpMethod.DELETE;
+import static org.springframework.http.HttpMethod.GET;
+import static org.springframework.http.HttpMethod.POST;
import com.fasterxml.jackson.core.JsonProcessingException;
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.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatusCode;
+import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
-import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestTemplate;
+import java.net.URI;
+
/**
* Simplification of RestTemplate for testing. It holds the last received response to ease testing
* flows.
@@ -20,26 +27,18 @@
public final class TestTemplate {
private static final Logger LOGGER = getLogger(TestTemplate.class);
-
- @SuppressWarnings("NullableProblems")
- private static final ResponseErrorHandler ERROR_HANDLER = new ResponseErrorHandler() {
- @Override
- public boolean hasError(ClientHttpResponse response) {
- return false;
- }
-
- @Override
- public void handleError(ClientHttpResponse response) {
- // Let RestTemplate return HTTP error codes without throwing exceptions
- }
- };
+ private static final ResponseErrorHandler ERROR_HANDLER = ignore -> false;
private final ObjectMapper mapper;
private final RestTemplate client;
+ private final String rootUri;
+ private final JwtTokenManager tokenManager;
private ResponseEntity lastResponse;
public TestTemplate(final String rootUri) {
+ this.rootUri = rootUri;
+ this.tokenManager = new JwtTokenManager();
final var restTemplateBuilder = new RestTemplateBuilder();
mapper = new ObjectMapper();
@@ -53,7 +52,7 @@ public TestTemplate(final String rootUri) {
@SuppressWarnings("UnusedReturnValue") // This is just a testing helper
public ResponseEntity get(final String path) {
LOGGER.debug("-> GET {}", path);
- lastResponse = client.getForEntity(path, String.class);
+ lastResponse = client.exchange(createRequest(GET, path), String.class);
LOGGER.debug("<- GET {}\n{}", path, lastResponse.getBody());
return lastResponse;
}
@@ -61,7 +60,7 @@ public ResponseEntity get(final String path) {
@SuppressWarnings("UnusedReturnValue") // This is just a testing helper
public ResponseEntity delete(final String path) {
LOGGER.debug("-> DELETE {}", path);
- lastResponse = client.exchange(path, HttpMethod.DELETE, null, String.class);
+ lastResponse = client.exchange(createRequest(DELETE, path), String.class);
LOGGER.debug("<- DELETE {}\n{}", path, lastResponse.getBody());
return lastResponse;
}
@@ -69,7 +68,7 @@ public ResponseEntity delete(final String path) {
@SuppressWarnings("UnusedReturnValue") // This is just a testing helper
public ResponseEntity post(final String path, final Object body) {
LOGGER.debug("-> POST {}", path);
- lastResponse = client.postForEntity(path, body, String.class);
+ lastResponse = client.exchange(createRequest(POST, path, body), String.class);
LOGGER.debug("<- POST {}\n{}", path, lastResponse.getBody());
return lastResponse;
}
@@ -90,8 +89,23 @@ public T getResponseBody(final Class type) {
return mapper.readValue(body, type);
}
catch (final JsonProcessingException e) {
- final var message = "Error mapping response body to '" + type.getName() + "':\n" + body;
+ final var message = "Error mapping response body to '%s':\n%s"
+ .formatted(type.getName(), body);
throw new RuntimeException(message, e);
}
}
+
+ private RequestEntity