diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index f177803..a420f6f 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -9,7 +9,6 @@ For the Pull Request to be accepted please check:
- [ ] If the PR refers to an issue, it should be referenced with the GitHub format (*#ID*).
- [ ] The PR is done to the `develop` branch (new features) or the `master` branch (releases).
- [ ] The code pass all PR checks.
-- [ ] All public members are documented.
- [ ] The code follow the coding conventions stated at the [contributing.md] file.
[contributing.md]: https://github.com/jaguililla/hexagonal_spring/blob/main/CONTRIBUTING.md
diff --git a/.gitlab/merge_request_templates/mr.md b/.gitlab/merge_request_templates/merge_request_template.md
similarity index 93%
rename from .gitlab/merge_request_templates/mr.md
rename to .gitlab/merge_request_templates/merge_request_template.md
index f177803..a420f6f 100644
--- a/.gitlab/merge_request_templates/mr.md
+++ b/.gitlab/merge_request_templates/merge_request_template.md
@@ -9,7 +9,6 @@ For the Pull Request to be accepted please check:
- [ ] If the PR refers to an issue, it should be referenced with the GitHub format (*#ID*).
- [ ] The PR is done to the `develop` branch (new features) or the `master` branch (releases).
- [ ] The code pass all PR checks.
-- [ ] All public members are documented.
- [ ] The code follow the coding conventions stated at the [contributing.md] file.
[contributing.md]: https://github.com/jaguililla/hexagonal_spring/blob/main/CONTRIBUTING.md
diff --git a/.mvn/parent.xml b/.mvn/parent.xml
index 73712c1..e859bb9 100644
--- a/.mvn/parent.xml
+++ b/.mvn/parent.xml
@@ -310,9 +310,7 @@
maven-checkstyle-plugin
3.5.0
- UTF-8
true
- false
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..18c9147
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,128 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+ overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+ advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+ address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.
diff --git a/README.md b/README.md
index c7283f7..e5fc028 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,7 @@ Example application to create appointments (REST API). Appointments are stored i
## ๐งช Test
* ArchUnit (preferred over Java modules: it allows naming checks, etc.)
* Testcontainers (used to provide a test instance of Postgres and Kafka)
+* Pitest (mutation testing, nightly)
## โ๏ธ Development
* SDKMAN (allows to use simpler runners on CI)
@@ -68,14 +69,14 @@ Example application to create appointments (REST API). Appointments are stored i
* No input ports: they don't need to be decoupled, they just use the domain (and that's acceptable).
## ๐ Architecture
-![Architecture Diagram](doc/architecture.svg)
-* **Port:** interface to set a boundary between application logic and implementation details.
-* **Adapter:**: port implementation to connect the application domain with the system's context.
-* **Domain:**: application logic and model entities.
-* **Service:**: implement operations with a common topic altogether. Usually calls driven ports.
-* **UseCase/Case:**: single operation service (isolate features). They can coexist with services.
-* **Output/Driven Adapter:**: implementation of ports called from the domain.
-* **Input/Driver Adapter:**: commands that call application logic (don't require a port).
+![Architecture Diagram](https://raw.githubusercontent.com/jaguililla/hexagonal_spring/main/doc/architecture.svg)
+* **Port**: interface to set a boundary between application logic and implementation details.
+* **Adapter**: port implementation to connect the application domain with the system's context.
+* **Domain**: application logic and model entities.
+* **Service**: implement operations with a common topic altogether. Usually calls driven ports.
+* **UseCase/Case**: single operation service (isolate features). They can coexist with services.
+* **Output/Driven Adapter**: implementation of ports called from the domain.
+* **Input/Driver Adapter**: commands that call application logic (don't require a port).
## ๐ Design
* The REST API controller and client are generated from the OpenAPI spec at build time.
diff --git a/pom.xml b/pom.xml
index 23cd11b..973acc1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -16,7 +16,7 @@
appointments
- 0.3.6
+ 0.3.7
Appointments
Application to create appointments (REST API)
diff --git a/src/main/java/com/github/jaguililla/appointments/ApplicationConfiguration.java b/src/main/java/com/github/jaguililla/appointments/ApplicationConfiguration.java
index 25a02b4..d26ec11 100644
--- a/src/main/java/com/github/jaguililla/appointments/ApplicationConfiguration.java
+++ b/src/main/java/com/github/jaguililla/appointments/ApplicationConfiguration.java
@@ -5,30 +5,18 @@
import com.github.jaguililla.appointments.domain.AppointmentsService;
import com.github.jaguililla.appointments.domain.UsersRepository;
import com.github.jaguililla.appointments.output.notifiers.KafkaTemplateAppointmentsNotifier;
-import com.github.jaguililla.appointments.output.repositories.JdbcTemplateAppointmentsRepository;
-import com.github.jaguililla.appointments.output.repositories.JdbcTemplateUsersRepository;
-import org.apache.kafka.clients.admin.AdminClientConfig;
-import org.apache.kafka.clients.admin.NewTopic;
-import org.apache.kafka.clients.consumer.ConsumerConfig;
-import org.apache.kafka.clients.producer.ProducerConfig;
-import org.apache.kafka.common.serialization.StringDeserializer;
-import org.apache.kafka.common.serialization.StringSerializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.*;
-import javax.sql.DataSource;
-import java.util.Map;
@Configuration
class ApplicationConfiguration {
private static final Logger LOGGER = LoggerFactory.getLogger(ApplicationConfiguration.class);
- @Value(value = "${spring.kafka.bootstrap-servers}")
- private String bootstrapAddress;
@Value(value = "${notifierTopic}")
private String notifierTopic;
@Value(value = "${createMessage}")
@@ -36,59 +24,6 @@ class ApplicationConfiguration {
@Value(value = "${deleteMessage}")
private String deleteMessage;
- @Bean
- public KafkaAdmin kafkaAdmin() {
- return new KafkaAdmin(Map.of(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapAddress));
- }
-
- @Bean
- public NewTopic appointmentsTopic() {
- return new NewTopic("appointments", 1, (short) 1);
- }
-
- @Bean
- public ProducerFactory producerFactory() {
- return new DefaultKafkaProducerFactory<>(Map.of(
- ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapAddress,
- ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class,
- ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class
- ));
- }
-
- @Bean
- public ConsumerFactory consumerFactory() {
- return new DefaultKafkaConsumerFactory<>(Map.of(
- ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapAddress,
- ConsumerConfig.GROUP_ID_CONFIG, "group",
- ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class,
- ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class
- ));
- }
-
- @Bean
- public KafkaTemplate kafkaTemplate(
- final ProducerFactory producerFactory,
- final ConsumerFactory consumerFactory
- ) {
- final var kafkaTemplate = new KafkaTemplate<>(producerFactory);
- kafkaTemplate.setConsumerFactory(consumerFactory);
- return kafkaTemplate;
- }
-
- @Bean
- AppointmentsRepository appointmentsStore(final DataSource dataSource) {
- final var type = JdbcTemplateAppointmentsRepository.class.getSimpleName();
- LOGGER.info("Creating Appointments Store: {}", type);
- return new JdbcTemplateAppointmentsRepository(dataSource);
- }
-
- @Bean
- UsersRepository usersStore(final DataSource dataSource) {
- final var type = JdbcTemplateUsersRepository.class.getSimpleName();
- LOGGER.info("Creating Users Store: {}", type);
- return new JdbcTemplateUsersRepository(dataSource);
- }
-
@Bean
AppointmentsNotifier appointmentsNotifier(final KafkaTemplate kafkaTemplate) {
final var type = KafkaTemplateAppointmentsNotifier.class.getSimpleName();
@@ -104,6 +39,7 @@ AppointmentsService appointmentsService(
final UsersRepository usersRepository,
final AppointmentsNotifier appointmentsNotifier
) {
- return new AppointmentsService(appointmentsRepository, usersRepository, appointmentsNotifier);
+ return
+ new AppointmentsService(appointmentsRepository, usersRepository, appointmentsNotifier);
}
}
diff --git a/src/main/java/com/github/jaguililla/appointments/output/notifiers/KafkaTemplateAppointmentsNotifier.java b/src/main/java/com/github/jaguililla/appointments/output/notifiers/KafkaTemplateAppointmentsNotifier.java
index 06c2dd4..0e0d0a9 100644
--- a/src/main/java/com/github/jaguililla/appointments/output/notifiers/KafkaTemplateAppointmentsNotifier.java
+++ b/src/main/java/com/github/jaguililla/appointments/output/notifiers/KafkaTemplateAppointmentsNotifier.java
@@ -3,6 +3,7 @@
import com.github.jaguililla.appointments.domain.AppointmentsNotifier;
import com.github.jaguililla.appointments.domain.Event;
import com.github.jaguililla.appointments.domain.model.Appointment;
+import java.util.concurrent.ExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.core.KafkaTemplate;
@@ -33,16 +34,24 @@ public KafkaTemplateAppointmentsNotifier(
public void notify(final Event event, final Appointment appointment) {
final var message = event == Event.CREATED ? createMessage : deleteMessage;
- kafkaTemplate
- .send(notifierTopic, message.formatted(appointment.start()))
- .whenComplete((result, e) -> {
- if (e == null) {
- final var metadata = result.getRecordMetadata();
- LOGGER.info("Message: '{}' offset: {}", message, metadata.offset());
- }
- else {
- LOGGER.info("Message: '{}' FAILED due to: {}", message, e.getMessage());
- }
- });
+ try {
+ kafkaTemplate
+ .send(notifierTopic, message.formatted(appointment.start()))
+ .whenComplete((result, e) -> {
+ if (e == null) {
+ final var metadata = result.getRecordMetadata();
+ LOGGER.info("Message: '{}' offset: {}", message, metadata.offset());
+ }
+ else {
+ LOGGER.info("Message: '{}' FAILED due to: {}", message, e.getMessage());
+ }
+ })
+ .get();
+ }
+ catch (InterruptedException | ExecutionException e) {
+ var id = appointment.id();
+ var errorMessage = "Error sending notification for appointment: %s".formatted(id);
+ throw new IllegalStateException(errorMessage, e);
+ }
}
}
diff --git a/src/main/java/com/github/jaguililla/appointments/output/repositories/JdbcTemplateAppointmentsRepository.java b/src/main/java/com/github/jaguililla/appointments/output/repositories/JdbcTemplateAppointmentsRepository.java
index c598477..ada05f9 100644
--- a/src/main/java/com/github/jaguililla/appointments/output/repositories/JdbcTemplateAppointmentsRepository.java
+++ b/src/main/java/com/github/jaguililla/appointments/output/repositories/JdbcTemplateAppointmentsRepository.java
@@ -14,7 +14,10 @@
import java.sql.SQLException;
import java.util.*;
import java.util.stream.Stream;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+@Repository
public class JdbcTemplateAppointmentsRepository implements AppointmentsRepository {
private static final Logger LOGGER =
@@ -40,11 +43,11 @@ public JdbcTemplateAppointmentsRepository(final DataSource dataSource) {
}
@Override
+ @Transactional
public boolean insert(final Appointment appointment) {
requireNonNull(appointment, "appointment cannot be null");
LOGGER.debug("--> Creating appointment: {}", appointment);
- // TODO Transaction
final var parameters = Map.of(
"id", appointment.id(),
"startTimestamp", appointment.start(),
@@ -66,11 +69,11 @@ public boolean insert(final Appointment appointment) {
}
@Override
+ @Transactional
public boolean delete(final UUID id) {
requireNonNull(id, "id cannot be null");
LOGGER.debug("--> Deleting aid: {}", id);
- // TODO Transaction
final var parameters = Map.of("id", id);
final var usersCount =
template.update("delete from AppointmentsUsers where appointmentId = :id", parameters);
diff --git a/src/main/java/com/github/jaguililla/appointments/output/repositories/JdbcTemplateUsersRepository.java b/src/main/java/com/github/jaguililla/appointments/output/repositories/JdbcTemplateUsersRepository.java
index 5131c9a..2cccad4 100644
--- a/src/main/java/com/github/jaguililla/appointments/output/repositories/JdbcTemplateUsersRepository.java
+++ b/src/main/java/com/github/jaguililla/appointments/output/repositories/JdbcTemplateUsersRepository.java
@@ -15,8 +15,10 @@
import java.util.Map;
import java.util.Set;
import java.util.UUID;
+import org.springframework.stereotype.Repository;
-public final class JdbcTemplateUsersRepository implements UsersRepository {
+@Repository
+public class JdbcTemplateUsersRepository implements UsersRepository {
private static final Logger LOGGER = LoggerFactory.getLogger(JdbcTemplateUsersRepository.class);
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index c0e6a93..ef32648 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -20,3 +20,12 @@ spring:
kafka:
bootstrap-servers: ${KAFKA_SERVER:localhost:9092}
+ producer:
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: org.apache.kafka.common.serialization.StringSerializer
+ # Consumer is only used in tests, auto-offset-reset is *REQUIRED*
+ consumer:
+ key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ group-id: tests
+ auto-offset-reset: earliest
diff --git a/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java b/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java
index 3d2f29f..a531975 100644
--- a/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java
+++ b/src/test/java/com/github/jaguililla/appointments/ApplicationIT.java
@@ -1,19 +1,21 @@
package com.github.jaguililla.appointments;
-import static java.util.Objects.requireNonNull;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
import com.github.jaguililla.appointments.http.controllers.messages.AppointmentRequest;
import com.github.jaguililla.appointments.http.controllers.messages.AppointmentResponse;
+import java.time.Duration;
+import java.util.List;
+import org.apache.kafka.common.TopicPartition;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
-import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
@@ -33,7 +35,7 @@ class ApplicationIT {
private final TestTemplate client;
@Autowired
- private KafkaTemplate kafkaTemplate;
+ private ConsumerFactory consumerFactory;
ApplicationIT(@LocalServerPort final int portTest) {
client = new TestTemplate("http://localhost:" + portTest);
@@ -90,15 +92,22 @@ void appointments_can_be_created_read_and_deleted() {
);
var response = client.getResponseBody(AppointmentResponse.class);
assertEquals(200, client.getResponseStatus().value());
- var creationMessage = requireNonNull(kafkaTemplate.receive("appointments", 0, 0));
- assertTrue(creationMessage.value().startsWith("Appointment created at"));
+ assertTrue(getLastMessage().startsWith("Appointment created at"));
client.get("/appointments/" + response.getId());
assertEquals(200, client.getResponseStatus().value());
client.delete("/appointments/" + response.getId());
assertEquals(200, client.getResponseStatus().value());
- var deletionMessage = requireNonNull(kafkaTemplate.receive("appointments", 0, 1));
- assertTrue(deletionMessage.value().startsWith("Appointment deleted at"));
+ assertTrue(getLastMessage().startsWith("Appointment deleted at"));
client.delete("/appointments/" + response.getId());
assertEquals(404, client.getResponseStatus().value());
}
+
+ private String getLastMessage() {
+ try (var consumer = consumerFactory.createConsumer()) {
+ consumer.assign(List.of(new TopicPartition("appointments", 0)));
+ var record = consumer.poll(Duration.ofMillis(250)).iterator().next().value();
+ consumer.commitSync();
+ return record;
+ }
+ }
}