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; + } + } }