diff --git a/.gitignore b/.gitignore index 96386e40..134dc599 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ infrastructure/templates/pgt-proxy/docker_image_builder_* infrastructure/templates/application/docker_image_builder_* application/frontend/node_modules application/frontend/run.sh +application/java-credit-card-product-service/target +*.jar* +*.class +*.lst diff --git a/README.MD b/README.MD index 6c2b7a46..73870049 100644 --- a/README.MD +++ b/README.MD @@ -1,11 +1,90 @@ # Notes +This repo is for the Ambar Event Sourcing / Microservices courses. +You can use this repo to deploy a real world credit card application which leverages both event sourcing and microservices +to create a larger application. Note that this is an academic example, implemented in multiple languages and with multiple +components - you should not expect to deeply understand everything in all the languages. + ## Preparation Steps +These steps will be performed with your instructor during the first coding session of your course, and will allow you to +deploy this repo into a cloud environment running on Google Cloud Platform (GCP) + 1. Fork this repository 2. Create two GitHub action secrets (provided by your course instructor) as follows: ``` STATE_MANAGEMENT_BASE64=some_string_here CREDENTIALS_BASE64=some_string_here ``` -3. Make a blank commit, and push it to GitHub. +**N.B. Make sure to enter the entire string for each secret, with no quotes, and no newlines!** +3. Make a trivial commit (such as updating this readme), and push it to GitHub. + +## Extending this program + +Our sample application attempts to model a financial institution which offers different credit cards (products). We have +already modeled some services of the application - some multiple times in multiple languages! To get started, you should +look at one of the already modeled services like the Credit-Card-Product service which is implemented in multiple languages +and try to follow along in one of the languages you are familiar with (php, java). Reimplement the service in your language +of choice, or add any missing features to the module. + +Once you have an understanding of the pattern as it works in your chosen implementation language, feel free to implement +another service of the application. If you feel inclined, open a pull request to the Ambar repo, and we will review and merge +it and extend the frontend application to leverage the capabilities! + +### Event Sourcing Additional Context / Reminders + +A quick reminder on Event Sourcing + +Event Sourcing is the inversion of state in an application. Instead of directly storing state in a record and performing CRUD +operations on those records, we record series of events that describe what happened in our application and derive state from +one or more of those events. This gives us a direct historical event sequence that we can use to build up state in many ways +and to even 'time travel' to see what state was in the past, without the need for audit tables or trying to deal with the +dual write problem. + +Note: This does not mean we do not directly store state in event sourcing! We can still distill information from our events +to build useful models about the state of our application and its data. This is where projections and read models come in. + +An easy way to organize event sourcing conceptually, and in code, is by leveraging the Command Query Responsibility Separation +pattern (CQRS) where we create distinct paths for our write (record events) and read (leverage events / state) actions. + +#### Terms refresher + +* **Command**: A request for something to happen in our application. + * E.G. Making a new credit card product for customers to apply for. +* **Event**: A recording of something significant happening in our application containing things like who, what, and when. +Events should be small and precise, and are written sequentially to an immutable log. Never change events or insert new ones +except at the tail of the log. Events should belong to short-lived processes, such as handling a request or system notification. + * E.G. Activating a credit card product so customers can apply for it +(Who: The product ID, What: Make it active, When: when was the event recorded) +* **Aggregate**: Information Model built from a set of related events used to model a process such has handling a command / request. +They should be small, and leverage event ordering to create a minified state for process handling. + * E.G. A Product Aggregate to model a credit card which is offered in our service. We can leverage the aggregate to determine +if a product is already available (active) or not, to determine if a command (request) is valid. +* **Projection**: Projection leverages building up some useful state from a filtered set of events and storing the derived +state into a projection database. These projections can then be leveraged during command validations when determining if +a command should be accepted (event written) or not. +* **Query**: Queries are the read side of our application, and leverage the models created by projections to retrieve information +store about the state of our application. + +``` +Comamnd (Read from EventStore/ReadModelStore, write back to EventStore) + -> [Command (Request)] -> [Build some state (EventStore Aggregate)] -> [Perform Validations (Aggregate, ReadModels)] -> + [Record whatever happened (New Event)] -> [EventStore] + +(Strongly Consistent) ^ +------------------------------------------------------------------------------------------------------------------------ +(Eventually Consistent) v + +Reaction (Event from EventStore, write back to EventStore) + -> [EventStore(Event)] -> [Build some state (EventStore Aggregate)] -> [Perform Validations (Aggregate, ReadModels)] -> + [Record whatever happened (New Event)] -> [EventStore] + + +Projections (Read from ReadModelStore, write to ReadModelStore) + -> [EventStore(Event)] -> [Build some state] -> [Project some interesting state] -> [ReadModelStore] + +Querys (Read Commands) (Read from ReadModelStore) + -> [ReadModelStore (Modeled State)] +``` + +### Microservices Additional Context / Reminders \ No newline at end of file diff --git a/application/java-credit-card-product-service/.dockerignore b/application/java-credit-card-product-service/.dockerignore new file mode 100644 index 00000000..07c3a32b --- /dev/null +++ b/application/java-credit-card-product-service/.dockerignore @@ -0,0 +1,2 @@ +code/vendor +code/development-environment \ No newline at end of file diff --git a/application/java-credit-card-product-service/CreditCardProduct.iml b/application/java-credit-card-product-service/CreditCardProduct.iml new file mode 100644 index 00000000..1e8535b5 --- /dev/null +++ b/application/java-credit-card-product-service/CreditCardProduct.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/java-credit-card-product-service/Dockerfile b/application/java-credit-card-product-service/Dockerfile new file mode 100644 index 00000000..b11b2143 --- /dev/null +++ b/application/java-credit-card-product-service/Dockerfile @@ -0,0 +1,30 @@ +# Step 1: Use Maven image to build the application +FROM maven:3.9.5-eclipse-temurin-21 AS build + +# Set the working directory inside the container +WORKDIR /app + +# Copy the pom.xml and download dependencies (layer caching) +COPY pom.xml . +RUN mvn dependency:go-offline + +# Copy the rest of the application source code +COPY src ./src + +# Build the Spring Boot application +RUN mvn clean package -DskipTests + +# Step 2: Use JDK 21 runtime image to run the application +FROM eclipse-temurin:21-jdk + +# Set the working directory for the runtime container +WORKDIR /app + +# Copy the JAR file from the build image to the runtime image +COPY --from=build /app/target/*.jar app.jar + +# Expose port 8080 (Spring Boot default port) +EXPOSE 8080 + +# Set the entry point to run the application +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/application/java-credit-card-product-service/development-environment/.gitignore b/application/java-credit-card-product-service/development-environment/.gitignore new file mode 100644 index 00000000..6320cd24 --- /dev/null +++ b/application/java-credit-card-product-service/development-environment/.gitignore @@ -0,0 +1 @@ +data \ No newline at end of file diff --git a/application/java-credit-card-product-service/development-environment/dev-commands.sh b/application/java-credit-card-product-service/development-environment/dev-commands.sh new file mode 100644 index 00000000..9d9f7b7b --- /dev/null +++ b/application/java-credit-card-product-service/development-environment/dev-commands.sh @@ -0,0 +1,41 @@ +endpoint="localhost:8080" + +# To create the Starter card +curl -X POST "${endpoint}/api/v1/credit_card_product/product" \ +-H "Content-Type: application/json" \ +-d '{ + "productIdentifierForAggregateIdHash": "STARTER_CREDIT_CARD", + "name": "Starter", + "interestInBasisPoints": 1200, + "annualFeeInCents": 5000, + "paymentCycle": "monthly", + "creditLimitInCents": 50000, + "maxBalanceTransferAllowedInCents": 0, + "reward": "none", + "cardBackgroundHex": "#7fffd4" +}' + +# To create the Platinum card +curl -X POST "${endpoint}/api/v1/credit_card_product/product" \ +-H "Content-Type: application/json" \ +-d '{ + "productIdentifierForAggregateIdHash": "PLATINUM_CREDIT_CARD", + "name": "Platinum", + "interestInBasisPoints": 300, + "annualFeeInCents": 50000, + "paymentCycle": "monthly", + "creditLimitInCents": 500000, + "maxBalanceTransferAllowedInCents": 100000, + "reward": "points", + "cardBackgroundHex": "#E5E4E2" +}' + +# To list the current card products +curl -X POST "${endpoint}/api/v1/credit_card_product/product/list-items" | jq . + +productId="" +# Activate a product +curl -X POST "${endpoint}/api/v1/credit_card_product/product/activate/${productId}" + +# Deactivate a product +curl -X POST "${endpoint}/api/v1/credit_card_product/product/deactivate/${productId}" \ No newline at end of file diff --git a/application/java-credit-card-product-service/development-environment/dev_a_start.sh b/application/java-credit-card-product-service/development-environment/dev_a_start.sh new file mode 100755 index 00000000..6b56cd94 --- /dev/null +++ b/application/java-credit-card-product-service/development-environment/dev_a_start.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -e + +#docker run --rm -v $(pwd):/app composer:2.2.24 install --ignore-platform-reqs +docker compose down +docker compose up -d --build --force-recreate +sleep 5 \ No newline at end of file diff --git a/application/java-credit-card-product-service/development-environment/dev_z_shutdown.sh b/application/java-credit-card-product-service/development-environment/dev_z_shutdown.sh new file mode 100755 index 00000000..64432589 --- /dev/null +++ b/application/java-credit-card-product-service/development-environment/dev_z_shutdown.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e + +docker compose down \ No newline at end of file diff --git a/application/java-credit-card-product-service/development-environment/docker-compose.yml b/application/java-credit-card-product-service/development-environment/docker-compose.yml new file mode 100644 index 00000000..9ede4856 --- /dev/null +++ b/application/java-credit-card-product-service/development-environment/docker-compose.yml @@ -0,0 +1,84 @@ +services: + + java-credit-card-product: + container_name: java-credit-card-product + build: + context: ./../ + volumes: + - ./../:/srv + restart: always + environment: + EVENT_STORE_HOST: "172.30.0.102" + EVENT_STORE_PORT: 5432 + EVENT_STORE_DATABASE_NAME: "my_es_database" + EVENT_STORE_USER: "my_es_username" + EVENT_STORE_PASSWORD: "my_es_password" + EVENT_STORE_CREATE_TABLE_WITH_NAME: "event_store" + EVENT_STORE_CREATE_REPLICATION_USER_WITH_USERNAME: "replication_username_test" + EVENT_STORE_CREATE_REPLICATION_USER_WITH_PASSWORD: "replication_password_test" + EVENT_STORE_CREATE_REPLICATION_PUBLICATION: "replication_publication_test" + MONGODB_PROJECTION_HOST: "172.30.0.103" + MONGODB_PROJECTION_PORT: 27017 + MONGODB_PROJECTION_AUTHENTICATION_DATABASE: "admin" + MONGODB_PROJECTION_DATABASE_NAME: "projections" + MONGODB_PROJECTION_DATABASE_USERNAME: "my_mongo_username" + MONGODB_PROJECTION_DATABASE_PASSWORD: "my_mongo_password" + SESSION_TOKENS_EXPIRE_AFTER_SECONDS: 3600 + depends_on: + - pg-event-store + - mongo-projection-reaction + networks: + TestNetwork: + ipv4_address: 172.30.0.101 + deploy: + resources: + limits: + cpus: '0.500' + memory: 1024M + + pg-event-store: + build: + context: + ./pg-event-store + container_name: pg-event-store + restart: always + volumes: + - ./data/event-store/pg-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: my_es_username + POSTGRES_DB: my_es_database + POSTGRES_PASSWORD: my_es_password + command: > + postgres -c wal_level=logical + -c ssl=on + -c ssl_cert_file=/var/lib/postgresql/certs/server.crt + -c ssl_key_file=/var/lib/postgresql/certs/server.key + expose: + - 5432 + networks: + TestNetwork: + ipv4_address: 172.30.0.102 + + mongo-projection-reaction: + build: + context: + ./mongo-projection-reaction + container_name: mongo-projection-reaction + restart: always + environment: + MONGO_INITDB_ROOT_USERNAME: my_mongo_username + MONGO_INITDB_ROOT_PASSWORD: my_mongo_password + volumes: + - ./data/mongo-projection-reaction/db-data:/data/db + expose: + - 27017 + networks: + TestNetwork: + ipv4_address: 172.30.0.103 + +networks: + TestNetwork: + driver: bridge + ipam: + config: + - subnet: 172.30.0.0/24 \ No newline at end of file diff --git a/application/java-credit-card-product-service/development-environment/mongo-projection-reaction/Dockerfile b/application/java-credit-card-product-service/development-environment/mongo-projection-reaction/Dockerfile new file mode 100644 index 00000000..d0605658 --- /dev/null +++ b/application/java-credit-card-product-service/development-environment/mongo-projection-reaction/Dockerfile @@ -0,0 +1 @@ +FROM mongo:7.0.14 \ No newline at end of file diff --git a/application/java-credit-card-product-service/development-environment/pg-event-store/Dockerfile b/application/java-credit-card-product-service/development-environment/pg-event-store/Dockerfile new file mode 100644 index 00000000..0e512219 --- /dev/null +++ b/application/java-credit-card-product-service/development-environment/pg-event-store/Dockerfile @@ -0,0 +1,9 @@ +FROM postgres:16.4 + +RUN mkdir -p /var/lib/postgresql/certs +RUN openssl genrsa -out /var/lib/postgresql/certs/server.key 2048 +RUN chmod 600 /var/lib/postgresql/certs/server.key +RUN openssl req -new -key /var/lib/postgresql/certs/server.key -out /var/lib/postgresql/certs/server.csr -subj "/C=GB/ST=London/L=London/O=SnakeOil/OU=Org/CN=snake-oil.oil" +RUN openssl x509 -req -in /var/lib/postgresql/certs/server.csr -signkey /var/lib/postgresql/certs/server.key -out /var/lib/postgresql/certs/server.crt -days 36500 +RUN chown 999 /var/lib/postgresql/certs +RUN chown 999 /var/lib/postgresql/certs -Rf \ No newline at end of file diff --git a/application/java-credit-card-product-service/pom.xml b/application/java-credit-card-product-service/pom.xml new file mode 100644 index 00000000..fb24cdbe --- /dev/null +++ b/application/java-credit-card-product-service/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.4 + + + cloud.ambar + CreditCardProduct + 0.0.1-SNAPSHOT + CreditCardProduct + Ambar Credit Card Product service for course demo + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + + org.postgresql + postgresql + + + + + org.projectlombok + lombok + 1.18.30 + compile + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${project.parent.version} + + + + + diff --git a/application/java-credit-card-product-service/src/commands.sh b/application/java-credit-card-product-service/src/commands.sh new file mode 100644 index 00000000..98c2cc34 --- /dev/null +++ b/application/java-credit-card-product-service/src/commands.sh @@ -0,0 +1,41 @@ +endpoint="your-endpoint-here" + +# To create the Starter card +curl -X POST "${endpoint}/api/v1/credit_card_product/product" \ +-H "Content-Type: application/json" \ +-d '{ + "productIdentifierForAggregateIdHash": "STARTER_CREDIT_CARD", + "name": "Starter", + "interestInBasisPoints": 1200, + "annualFeeInCents": 5000, + "paymentCycle": "monthly", + "creditLimitInCents": 50000, + "maxBalanceTransferAllowedInCents": 0, + "reward": "none", + "cardBackgroundHex": "#7fffd4" +}' + +# To create the Platinum card +curl -X POST "${endpoint}/api/v1/credit_card_product/product" \ +-H "Content-Type: application/json" \ +-d '{ + "productIdentifierForAggregateIdHash": "PLATINUM_CREDIT_CARD", + "name": "Platinum", + "interestInBasisPoints": 300, + "annualFeeInCents": 50000, + "paymentCycle": "monthly", + "creditLimitInCents": 500000, + "maxBalanceTransferAllowedInCents": 100000, + "reward": "points", + "cardBackgroundHex": "#E5E4E2" +}' + +# To list the current card products +curl -X POST "${endpoint}/api/v1/credit_card_product/product/list-items" | jq . + +productId="" +# Activate a product +curl -X POST "${endpoint}/api/v1/credit_card_product/product/activate/${productId}" + +# Deactivate a product +curl -X POST "${endpoint}/api/v1/credit_card_product/product/deactivate/${productId}" \ No newline at end of file diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/Application.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/Application.java new file mode 100644 index 00000000..8106b0e0 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/Application.java @@ -0,0 +1,26 @@ +package cloud.ambar; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + private static final Logger log = LogManager.getLogger(Application.class); + + + /** + * Spring Application which will run a webserver hosting our EventSourcing java application. + * On startup, this application will + * 1. Validate any tables for EventStorage / Projection & Reaction are created + * 2. Populate with any sample values (Sample credit card products) + * 3. Start the application and make it available. + * @param args + */ + public static void main(String[] args) { + log.info("Starting up main application"); + + SpringApplication.run(Application.class, args); + } +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/aggregate/Aggregate.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/aggregate/Aggregate.java new file mode 100644 index 00000000..983b2e2e --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/aggregate/Aggregate.java @@ -0,0 +1,7 @@ +package cloud.ambar.creditCardProduct.aggregate; + +import cloud.ambar.creditCardProduct.events.Event; + +public interface Aggregate { + void transform(final Event event); +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/aggregate/AggregateTraits.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/aggregate/AggregateTraits.java new file mode 100644 index 00000000..dd6de11d --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/aggregate/AggregateTraits.java @@ -0,0 +1,39 @@ +package cloud.ambar.creditCardProduct.aggregate; + +import cloud.ambar.creditCardProduct.controllers.QueryController; +import cloud.ambar.creditCardProduct.exceptions.InvalidEventException; +import cloud.ambar.creditCardProduct.events.Event; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Objects; + +@Data +@NoArgsConstructor +public abstract class AggregateTraits implements Aggregate { + private static final Logger log = LogManager.getLogger(AggregateTraits.class); + + private String aggregateId; + private long aggregateVersion; + + public AggregateTraits(String aggregateId, long aggregateVersion) { + this.aggregateId = aggregateId; + this.aggregateVersion = aggregateVersion; + } + + public void apply(final Event event) { + log.info("Applying Event: " + event); + this.validateEvent(event); + transform(event); + + this.aggregateVersion++; + } + + private void validateEvent(final Event event) { + log.info("Validating Event: " + event); + if (Objects.isNull(event) || !event.getAggregateId().equals(this.aggregateId)) + throw new InvalidEventException(event.toString()); + } +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/aggregate/ProductAggregate.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/aggregate/ProductAggregate.java new file mode 100644 index 00000000..e2da5afc --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/aggregate/ProductAggregate.java @@ -0,0 +1,73 @@ +package cloud.ambar.creditCardProduct.aggregate; + +import cloud.ambar.creditCardProduct.events.Event; +import cloud.ambar.creditCardProduct.events.ProductActivatedEvent; +import cloud.ambar.creditCardProduct.events.ProductDeactivatedEvent; +import cloud.ambar.creditCardProduct.events.ProductDefinedEvent; +import cloud.ambar.creditCardProduct.data.models.PaymentCycle; +import cloud.ambar.creditCardProduct.data.models.RewardsType; +import cloud.ambar.creditCardProduct.exceptions.InvalidEventException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +@EqualsAndHashCode(callSuper = true) +@Data +@NoArgsConstructor +public class ProductAggregate extends AggregateTraits { + private static final Logger log = LogManager.getLogger(ProductAggregate.class); + private String name; + private int interestInBasisPoints; + private int annualFeeInCents; + private String paymentCycle; + private int creditLimitInCents; + private int maxBalanceTransferAllowedInCents; + private String reward; + private String cardBackgroundHex; + private boolean active; + + public ProductAggregate(String aggregateId, long aggregateVersion) { + super(aggregateId, aggregateVersion); + } + + @Override + public void transform(Event event) { + switch(event.getEventName()) { + case ProductDefinedEvent.EVENT_NAME -> { + log.info("Transforming aggregate for ProductDefinedEvent"); + ObjectMapper om = new ObjectMapper(); + try { + ProductDefinedEvent definition = om.readValue(event.getData(), ProductDefinedEvent.class); + this.setAggregateId(event.getAggregateId()); + this.setAggregateVersion(event.getVersion()); + this.setName(definition.getName()); + this.setInterestInBasisPoints(definition.getInterestInBasisPoints()); + this.setAnnualFeeInCents(definition.getAnnualFeeInCents()); + this.setPaymentCycle(definition.getPaymentCycle()); + this.setCreditLimitInCents(definition.getCreditLimitInCents()); + this.setMaxBalanceTransferAllowedInCents(definition.getMaxBalanceTransferAllowedInCents()); + this.setReward(definition.getReward()); + this.setCardBackgroundHex(definition.getCardBackgroundHex()); + this.setActive(false); + } catch (JsonProcessingException e) { + log.error("Error creating initial product definition from event!"); + throw new InvalidEventException("Error processing ProductDefinedEvent"); + } + } + case ProductActivatedEvent.EVENT_NAME -> { + log.info("Transforming aggregate for ProductActivatedEvent"); + this.active = true; + } + case ProductDeactivatedEvent.EVENT_NAME -> { + log.info("Transforming aggregate for ProductDeactivatedEvent"); + this.active = false; + } + } + } +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/aggregate/README.md b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/aggregate/README.md new file mode 100644 index 00000000..39c7dfcb --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/aggregate/README.md @@ -0,0 +1,3 @@ +# Aggregates + +Aggregates are \ No newline at end of file diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commandHandlers/CommandService.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commandHandlers/CommandService.java new file mode 100644 index 00000000..64853f70 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commandHandlers/CommandService.java @@ -0,0 +1,12 @@ +package cloud.ambar.creditCardProduct.commandHandlers; + +import cloud.ambar.creditCardProduct.commands.DefineProductCommand; +import cloud.ambar.creditCardProduct.commands.ProductActivatedCommand; +import cloud.ambar.creditCardProduct.commands.ProductDeactivatedCommand; +import com.fasterxml.jackson.core.JsonProcessingException; + +public interface CommandService { + void handle(DefineProductCommand command) throws JsonProcessingException; + void handle(ProductActivatedCommand command) throws JsonProcessingException; + void handle(ProductDeactivatedCommand command) throws JsonProcessingException; +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commandHandlers/ProductCommandService.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commandHandlers/ProductCommandService.java new file mode 100644 index 00000000..64fe6681 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commandHandlers/ProductCommandService.java @@ -0,0 +1,194 @@ +package cloud.ambar.creditCardProduct.commandHandlers; + +import cloud.ambar.creditCardProduct.aggregate.ProductAggregate; +import cloud.ambar.creditCardProduct.exceptions.InvalidEventException; +import cloud.ambar.creditCardProduct.exceptions.InvalidPaymentCycleException; +import cloud.ambar.creditCardProduct.exceptions.InvalidRewardException; +import cloud.ambar.creditCardProduct.events.Event; +import cloud.ambar.creditCardProduct.commands.DefineProductCommand; +import cloud.ambar.creditCardProduct.commands.ProductActivatedCommand; +import cloud.ambar.creditCardProduct.commands.ProductDeactivatedCommand; +import cloud.ambar.creditCardProduct.data.postgre.EventRepository; +import cloud.ambar.creditCardProduct.events.ProductActivatedEvent; +import cloud.ambar.creditCardProduct.events.ProductDeactivatedEvent; +import cloud.ambar.creditCardProduct.events.ProductDefinedEvent; +import cloud.ambar.creditCardProduct.data.models.PaymentCycle; +import cloud.ambar.creditCardProduct.data.models.RewardsType; +import cloud.ambar.creditCardProduct.exceptions.NoSuchProductException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@RequiredArgsConstructor +@Service +@Transactional +public class ProductCommandService implements CommandService { + private static final Logger log = LogManager.getLogger(ProductCommandService.class); + + private final EventRepository eventStore; + + private final ObjectMapper objectMapper; + + @Override + public void handle(DefineProductCommand command) throws JsonProcessingException { + log.info("Handling " + ProductDefinedEvent.EVENT_NAME + " command."); + final String eventId = UUID.nameUUIDFromBytes(command.getProductIdentifierForAggregateIdHash().getBytes()).toString(); + // First part of validation is to check if this event has already been processed. We expect to create a new + // unique aggregate from this and subsequent events. If it is already present, then we are processing a duplicate + // event. + Optional priorEntry = eventStore.findByEventId(eventId); + if (priorEntry.isPresent()) { + log.info("Found event(s) for eventId - skipping..."); + return; + } + + // Next, some simple business validations. These do not rely on any read models (queries) + if (Arrays.stream(PaymentCycle.values()).noneMatch(p -> p.name().equalsIgnoreCase(command.getPaymentCycle()))) { + log.error("Invalid payment cycle was specified in command: " + command.getPaymentCycle()); + throw new InvalidPaymentCycleException(); + } + + // ... + if (Arrays.stream(RewardsType.values()).noneMatch(p -> p.name().equalsIgnoreCase(command.getReward()))) { + log.error("Invalid reward was specified in command: " + command.getReward()); + throw new InvalidRewardException(); + } + + // Finally, we have passed all the validations, and want to 'accept' (store) the result event. So we will create + // the resultant event with related details (product definition) and write this to our event store. + final String aggregateId = UUID.randomUUID().toString(); + final Event event = Event.builder() + .eventName(ProductDefinedEvent.EVENT_NAME) + .eventId(eventId) + .correlationId(eventId) + .causationID(eventId) + .aggregateId(aggregateId) + .version(1) + .timeStamp(LocalDateTime.now()) + .metadata("") + .data(objectMapper.writeValueAsString( + ProductDefinedEvent.builder() + .name(command.getName()) + .interestInBasisPoints(command.getInterestInBasisPoints()) + .annualFeeInCents(command.getAnnualFeeInCents()) + .paymentCycle(command.getPaymentCycle()) + .creditLimitInCents(command.getCreditLimitInCents()) + .maxBalanceTransferAllowedInCents(command.getMaxBalanceTransferAllowedInCents()) + .reward(command.getReward()) + .cardBackgroundHex(command.getCardBackgroundHex()) + .build() + )) + .build(); + + log.info("Saving Event: " + objectMapper.writeValueAsString(event)); + eventStore.save(event); + log.info("Successfully handled " + ProductDefinedEvent.EVENT_NAME + " command."); + } + + @Override + public void handle(ProductActivatedCommand command) throws JsonProcessingException { + log.info("Handling " + ProductActivatedEvent.EVENT_NAME + " command."); + final String aggregateId = command.getId(); + + // 1. Hydrate the Aggregate + final ProductAggregate aggregate = hydrateAggregateForId(aggregateId);; + + // 2. Validate the command + // -> card currently inactive + // This can be done with either a query to the projection DB (async) + // Or via the Aggregate (sync) for this trivial example, we will use the aggregate. + if (aggregate.isActive()) { + final String msg = "Product " + aggregateId + " is already active!"; + throw new InvalidEventException(msg); + } + log.info("Product is currently inactive, updating to active!"); + + // 3. Update the aggregate (write new event to store) + final String eventId = UUID.randomUUID().toString(); + final Event event = Event.builder() + .eventName(ProductActivatedEvent.EVENT_NAME) + .eventId(eventId) + .correlationId(aggregateId) + .causationID(eventId) + .aggregateId(aggregateId) + .version(aggregate.getAggregateVersion()) + .timeStamp(LocalDateTime.now()) + .metadata("") + .data(objectMapper.writeValueAsString( + ProductActivatedEvent.builder() + .aggregateId(aggregateId) + .build() + )) + .build(); + + log.info("Saving Event: " + objectMapper.writeValueAsString(event)); + eventStore.save(event); + log.info("Successfully handled " + ProductDefinedEvent.EVENT_NAME + " command."); + } + + @Override + public void handle(ProductDeactivatedCommand command) throws JsonProcessingException { + log.info("Handling " + ProductDeactivatedEvent.EVENT_NAME + " command."); + final String aggregateId = command.getId(); + + // 1. Hydrate the Aggregate + final ProductAggregate aggregate = hydrateAggregateForId(aggregateId); + + // 2. Validate the command + // -> card currently inactive + // This can be done with either a query to the projection DB (async) + // Or via the Aggregate (sync) for this trivial example, we will use the aggregate. + if (!aggregate.isActive()) { + final String msg = "Product " + aggregateId + " is already inactive!"; + throw new InvalidEventException(msg); + } + log.info("Product is currently active, updating to active!"); + + // 3. Update the aggregate (write new event to store) + final String eventId = UUID.randomUUID().toString(); + final Event event = Event.builder() + .eventName(ProductDeactivatedEvent.EVENT_NAME) + .eventId(eventId) + .correlationId(aggregateId) + .causationID(eventId) + .aggregateId(aggregateId) + .version(aggregate.getAggregateVersion()) + .timeStamp(LocalDateTime.now()) + .metadata("") + .data(objectMapper.writeValueAsString( + ProductDeactivatedEvent.builder() + .aggregateId(aggregateId) + .build() + )) + .build(); + + log.info("Saving Event: " + objectMapper.writeValueAsString(event)); + eventStore.save(event); + log.info("Successfully handled " + ProductDeactivatedEvent.EVENT_NAME + " command."); + } + + private ProductAggregate hydrateAggregateForId(String id) { + final List productEvents = eventStore.findAllByAggregateId(id); + final ProductAggregate aggregate = new ProductAggregate(id, 0); + if (productEvents.isEmpty()) { + final String msg = "Unable to find a product with id: " + id; + throw new NoSuchProductException(msg); + } + + for (Event event: productEvents) { + aggregate.apply(event); + } + log.info("Hydrated Aggregate: " + aggregate); + return aggregate; + } +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commandHandlers/README.md b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commandHandlers/README.md new file mode 100644 index 00000000..25a6cd05 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commandHandlers/README.md @@ -0,0 +1 @@ +Todo: Add details about the classes in this dir \ No newline at end of file diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commands/DefineProductCommand.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commands/DefineProductCommand.java new file mode 100644 index 00000000..b06b3ce8 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commands/DefineProductCommand.java @@ -0,0 +1,21 @@ +package cloud.ambar.creditCardProduct.commands; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DefineProductCommand { + private String productIdentifierForAggregateIdHash; + private String name; + private int interestInBasisPoints; + private int annualFeeInCents; + private String paymentCycle; + private int creditLimitInCents; + private int maxBalanceTransferAllowedInCents; + private String reward; + private String cardBackgroundHex; +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commands/ProductActivatedCommand.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commands/ProductActivatedCommand.java new file mode 100644 index 00000000..16a764d6 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commands/ProductActivatedCommand.java @@ -0,0 +1,12 @@ +package cloud.ambar.creditCardProduct.commands; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProductActivatedCommand { + private String id; +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commands/ProductDeactivatedCommand.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commands/ProductDeactivatedCommand.java new file mode 100644 index 00000000..833af53f --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commands/ProductDeactivatedCommand.java @@ -0,0 +1,12 @@ +package cloud.ambar.creditCardProduct.commands; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProductDeactivatedCommand { + private String id; +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commands/README.md b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commands/README.md new file mode 100644 index 00000000..25a6cd05 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/commands/README.md @@ -0,0 +1 @@ +Todo: Add details about the classes in this dir \ No newline at end of file diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/controllers/CommandController.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/controllers/CommandController.java new file mode 100644 index 00000000..50a48c47 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/controllers/CommandController.java @@ -0,0 +1,61 @@ +package cloud.ambar.creditCardProduct.controllers; + +import cloud.ambar.creditCardProduct.commandHandlers.ProductCommandService; +import cloud.ambar.creditCardProduct.commands.DefineProductCommand; +import cloud.ambar.creditCardProduct.commands.ProductActivatedCommand; +import cloud.ambar.creditCardProduct.commands.ProductDeactivatedCommand; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * This controller will handle requests from the frontend which are commands that result in events written + * to the event store. Events such as defining a product, activating a product, and deactivating a product. + * It will leverage the read side of the application to perform validations and determine if we should accept + * or reject a command before recording it. + * This is the write side of our application. + * Requests (Commands) to handle: + * - DefineProduct + * - ActivateProduct + * - DeactivateProduct + */ +@Controller +@RequestMapping("/api/v1/credit_card_product/product") +public class CommandController { + private static final Logger log = LogManager.getLogger(CommandController.class); + + private final ProductCommandService productService; + + @Autowired + public CommandController(final ProductCommandService productService) { + this.productService = productService; + } + + @PostMapping + @ResponseStatus(HttpStatus.OK) + public void defineProduct(@RequestBody DefineProductCommand defineProductCommand) throws JsonProcessingException { + log.info("Got request to define product."); + // Todo: Validate the request (Required args present, etc) + productService.handle(defineProductCommand); + } + + @PostMapping("/activate/{aggregateId}") + @ResponseStatus(HttpStatus.OK) + public void activateProduct(@PathVariable String aggregateId) throws JsonProcessingException { + productService.handle(new ProductActivatedCommand(aggregateId)); + } + + @PostMapping("/deactivate/{aggregateId}") + @ResponseStatus(HttpStatus.OK) + public void deactivateProduct(@PathVariable String aggregateId) throws JsonProcessingException { + productService.handle(new ProductDeactivatedCommand(aggregateId)); + } +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/controllers/EventController.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/controllers/EventController.java new file mode 100644 index 00000000..79c10a74 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/controllers/EventController.java @@ -0,0 +1,101 @@ +package cloud.ambar.creditCardProduct.controllers; + +import cloud.ambar.creditCardProduct.data.models.ambar.AmbarResponse; +import cloud.ambar.creditCardProduct.data.models.ambar.Error; +import cloud.ambar.creditCardProduct.data.models.ambar.ErrorPolicy; +import cloud.ambar.creditCardProduct.data.models.ambar.Result; +import cloud.ambar.creditCardProduct.data.models.ambar.Success; +import cloud.ambar.creditCardProduct.data.models.projection.AmbarEvent; +import cloud.ambar.creditCardProduct.projection.ProductProjectorService; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * This controller is responsible for updating the read models from events written to the eventstore (postgre). + * It is notified of new events on an endpoint, and then will perform any necessary actions to retrieve + * and update corresponding models in the ReadModelRepository (mongo). + * + * This is the Projection/Reaction side of our application + * Note: This service does not write any new events in response to incoming events, and thus does not have a reaction portion + */ +@RestController +public class EventController { + private static final Logger log = LogManager.getLogger(EventController.class); + + private final ProductProjectorService productProjectorService; + + private final ObjectMapper objectMapper; + + public EventController(final ProductProjectorService productProjectorService) { + this.productProjectorService = productProjectorService; + this.objectMapper = new ObjectMapper(); + } + + @PostMapping(value = "/api/v1/credit_card_product/product/projection", + consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public AmbarResponse handleEvent(HttpServletRequest httpServletRequest) { + try { + final AmbarEvent ambarEvent = extractEvent(httpServletRequest); + log.info("Got event: " + ambarEvent); + + productProjectorService.project(ambarEvent.getPayload()); + return successResponse(); + } catch (Exception e) { + log.error("Failed to process projection event!"); + log.error(e); + log.error(e.getMessage()); + return retryResponse(e.getMessage()); + } + } + + private AmbarResponse retryResponse(String err) { + return AmbarResponse.builder() + .result(Result.builder() + .error(Error.builder() + .policy(ErrorPolicy.MUST_RETRY.toString()) + .description(err) + .build()) + .build()) + .build(); + } + + private AmbarResponse successResponse() { + return AmbarResponse.builder() + .result(Result.builder() + .success(new Success()) + .build()) + .build(); + } + + // Pulls the Ambar event as a string from the request. + private AmbarEvent extractEvent(HttpServletRequest httpServletRequest) throws IOException { + final ServletInputStream inputStream; + + try { + inputStream = httpServletRequest.getInputStream(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + final ByteArrayOutputStream result = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + for (int length; (length = inputStream.read(buffer)) != -1; ) { + result.write(buffer, 0, length); + } + + log.info("Got message: " + result); + + return objectMapper.readValue(result.toString(StandardCharsets.UTF_8), AmbarEvent.class); + } +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/controllers/QueryController.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/controllers/QueryController.java new file mode 100644 index 00000000..2b22d382 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/controllers/QueryController.java @@ -0,0 +1,43 @@ +package cloud.ambar.creditCardProduct.controllers; + +import cloud.ambar.creditCardProduct.data.models.projection.Product; +import cloud.ambar.creditCardProduct.query.QueryService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * This controller will handle endpoints related to querying details about products for the front end. + * These endpoints do not handle any commands and just return things back from the ReadModelRepository as + * written by projections and reactions. + * This is the Read side of our application + * Requests to handle: + * - ListProducts + */ +@RestController +public class QueryController { + private static final Logger log = LogManager.getLogger(QueryController.class); + + private final QueryService queryService; + + private final ObjectMapper objectMapper; + + public QueryController(QueryService queryService) { + this.queryService = queryService; + this.objectMapper = new ObjectMapper(); + } + + @PostMapping(value = "/api/v1/credit_card_product/product/list-items") + public String listItems() throws JsonProcessingException { + log.info("Listing all products from ProjectionRepository"); + List products = queryService.getAllProductListItems(); + // Todo: Create the response shape and serialize it. + return objectMapper.writeValueAsString(products); + } + +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/controllers/README.md b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/controllers/README.md new file mode 100644 index 00000000..25a6cd05 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/controllers/README.md @@ -0,0 +1 @@ +Todo: Add details about the classes in this dir \ No newline at end of file diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/PaymentCycle.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/PaymentCycle.java new file mode 100644 index 00000000..7de2e526 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/PaymentCycle.java @@ -0,0 +1,7 @@ +package cloud.ambar.creditCardProduct.data.models; + +public enum PaymentCycle { + MONTHLY, + QUARTERLY, + UNKNOWN +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/RewardsType.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/RewardsType.java new file mode 100644 index 00000000..07ba645a --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/RewardsType.java @@ -0,0 +1,8 @@ +package cloud.ambar.creditCardProduct.data.models; + +public enum RewardsType { + POINTS, + CASHBACK, + NONE, + UNKNOWN +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/AmbarResponse.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/AmbarResponse.java new file mode 100644 index 00000000..0ff0c1d7 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/AmbarResponse.java @@ -0,0 +1,16 @@ +package cloud.ambar.creditCardProduct.data.models.ambar; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AmbarResponse { + @JsonProperty("result") + private Result result; +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/Error.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/Error.java new file mode 100644 index 00000000..f2476a97 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/Error.java @@ -0,0 +1,22 @@ +package cloud.ambar.creditCardProduct.data.models.ambar; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Error { + @JsonProperty("policy") + private String policy; + + @JsonProperty("class") + private String errorClass; + + @JsonProperty("description") + private String description; +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/ErrorPolicy.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/ErrorPolicy.java new file mode 100644 index 00000000..018f40c2 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/ErrorPolicy.java @@ -0,0 +1,10 @@ +package cloud.ambar.creditCardProduct.data.models.ambar; + +public enum ErrorPolicy { + KEEP_GOING, + MUST_RETRY; + + public String toString() { + return name().toLowerCase(); + } +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/README.md b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/README.md new file mode 100644 index 00000000..e69de29b diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/Result.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/Result.java new file mode 100644 index 00000000..f1677224 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/Result.java @@ -0,0 +1,18 @@ +package cloud.ambar.creditCardProduct.data.models.ambar; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Result { + @JsonProperty("error") + private Error error; + @JsonProperty("success") + private Success success; +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/Success.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/Success.java new file mode 100644 index 00000000..d1d57628 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/ambar/Success.java @@ -0,0 +1,17 @@ +package cloud.ambar.creditCardProduct.data.models.ambar; + + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Success { + private JsonNode node; +} + diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/projection/AmbarEvent.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/projection/AmbarEvent.java new file mode 100644 index 00000000..5c344283 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/projection/AmbarEvent.java @@ -0,0 +1,23 @@ +package cloud.ambar.creditCardProduct.data.models.projection; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class AmbarEvent { + @JsonProperty("data_source_id") + private String dataSourceId; + @JsonProperty("data_source_description") + private String dataSourceDescription; + @JsonProperty("data_destination_id") + private String dataDestinationId; + @JsonProperty("data_destination_description") + private String dataDestinationDescription; + @JsonProperty("payload") + private Payload payload; +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/projection/Payload.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/projection/Payload.java new file mode 100644 index 00000000..a75d447f --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/projection/Payload.java @@ -0,0 +1,35 @@ +package cloud.ambar.creditCardProduct.data.models.projection; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class Payload { + @JsonProperty("id") + private Long id; + + @JsonProperty("event_id") + private String eventId; + + @JsonProperty("aggregate_id") + private String aggregateId; + + @JsonProperty("causation_id") + private String causationID; + + @JsonProperty("correlation_id") + private String correlationId; + + @JsonProperty("aggregate_version") + private long version; + + @JsonProperty("json_payload") + private String data; + + @JsonProperty("event_name") + private String eventName; +} \ No newline at end of file diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/projection/Product.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/projection/Product.java new file mode 100644 index 00000000..155e1e73 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/projection/Product.java @@ -0,0 +1,20 @@ +package cloud.ambar.creditCardProduct.data.models.projection; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.persistence.Id; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Document; + +@Data +@NoArgsConstructor +@Document(collection = "ProductListItems") +@JsonIgnoreProperties(ignoreUnknown = true) +public class Product { + @Id + private String id; + private String name; + @JsonProperty("isActive") + private boolean active; +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/projection/README.md b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/models/projection/README.md new file mode 100644 index 00000000..e69de29b diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/mongo/ProjectionRepository.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/mongo/ProjectionRepository.java new file mode 100644 index 00000000..6460357a --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/mongo/ProjectionRepository.java @@ -0,0 +1,9 @@ +package cloud.ambar.creditCardProduct.data.mongo; + +import cloud.ambar.creditCardProduct.data.models.projection.Product; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.Optional; + +public interface ProjectionRepository extends MongoRepository { +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/postgre/EventRepository.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/postgre/EventRepository.java new file mode 100644 index 00000000..60a15fe4 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/postgre/EventRepository.java @@ -0,0 +1,15 @@ +package cloud.ambar.creditCardProduct.data.postgre; + +import cloud.ambar.creditCardProduct.events.Event; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +// @Repository is not strictly required. Added here for clarity. +@Repository +public interface EventRepository extends JpaRepository { + List findAllByAggregateId(String aggregateId); + Optional findByEventId(String eventId); +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/postgre/EventStoreInitializer.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/postgre/EventStoreInitializer.java new file mode 100644 index 00000000..27830dad --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/data/postgre/EventStoreInitializer.java @@ -0,0 +1,132 @@ +package cloud.ambar.creditCardProduct.data.postgre; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * By making this a component, we tell Spring to initialize this class and make it available to the Application Context + * by doing this, along with ApplicationRunner bean, we can have this code run on startup of the application and ensure + * that our event store is ready for us. + */ +@Component +public class EventStoreInitializer { + private static final Logger log = LogManager.getLogger(EventStoreInitializer.class); + + private final JdbcTemplate jdbcTemplate; + + @Value("${EVENT_STORE_CREATE_TABLE_WITH_NAME}") + private String eventStoreTableName; + + @Value("${EVENT_STORE_CREATE_REPLICATION_USER_WITH_USERNAME}") + private String eventStoreCreateReplicationUserWithUsername; + + @Value("${EVENT_STORE_CREATE_REPLICATION_USER_WITH_PASSWORD}") + private String eventStoreCreateReplicationUserWithPassword; + + @Value("${EVENT_STORE_DATABASE_NAME}") + private String eventStoreDatabaseName; + + @Value("${EVENT_STORE_CREATE_REPLICATION_PUBLICATION}") + private String eventStoreCreateReplicationPublication; + + public EventStoreInitializer(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Bean + ApplicationRunner initDatabase() { + return args -> { + // Create table + log.info("Creating table " + eventStoreTableName); + executeStatementIgnoreErrors( + String.format(""" + CREATE TABLE IF NOT EXISTS %s ( + id BIGSERIAL NOT NULL, + event_id TEXT NOT NULL UNIQUE, + aggregate_id TEXT NOT NULL, + aggregate_version BIGINT NOT NULL, + causation_id TEXT NOT NULL, + correlation_id TEXT NOT NULL, + recorded_on TEXT NOT NULL, + event_name TEXT NOT NULL, + json_payload TEXT NOT NULL, + json_metadata TEXT NOT NULL, + PRIMARY KEY (id));""", + eventStoreTableName) + ); + + // Create user + log.info("Creating replication user"); + executeStatementIgnoreErrors(String.format( + "CREATE USER %s REPLICATION LOGIN PASSWORD '%s';", + eventStoreCreateReplicationUserWithUsername, + eventStoreCreateReplicationUserWithPassword + )); + + // Grant permissions to user + log.info("Granting permissions to replication user"); + executeStatementIgnoreErrors(String.format( + "GRANT CONNECT ON DATABASE \"%s\" TO %s;", + eventStoreDatabaseName, + eventStoreCreateReplicationUserWithUsername + )); + + log.info("Granting select to replication user"); + executeStatementIgnoreErrors(String.format( + "GRANT SELECT ON TABLE %s TO %s;", + eventStoreTableName, + eventStoreCreateReplicationUserWithUsername + )); + + // Create publication + log.info("Creating publication for table"); + executeStatementIgnoreErrors(String.format( + "CREATE PUBLICATION %s FOR TABLE %s;", + eventStoreCreateReplicationPublication, + eventStoreTableName + )); + + // Create indices + log.info("Creating aggregate index"); + executeStatementIgnoreErrors(String.format( + "CREATE UNIQUE INDEX event_store_idx_event_aggregate_id_version ON %s(aggregate_id, aggregate_version);", + eventStoreTableName + )); + log.info("Creating causation index"); + executeStatementIgnoreErrors(String.format( + "CREATE INDEX event_store_idx_event_causation_id ON %s(causation_id);", + eventStoreTableName + )); + log.info("Creating correlation index"); + executeStatementIgnoreErrors(String.format( + "CREATE INDEX event_store_idx_event_correlation_id ON %s(correlation_id);", + eventStoreTableName + )); + log.info("Creating recording index"); + executeStatementIgnoreErrors(String.format( + "CREATE INDEX event_store_idx_occurred_on ON %s(recorded_on);", + eventStoreTableName + )); + log.info("Creating event name index"); + executeStatementIgnoreErrors(String.format( + "CREATE INDEX event_store_idx_event_name ON %s(event_name);", + eventStoreTableName + )); + }; + } + + private void executeStatementIgnoreErrors(final String sqlStatement) { + try { + log.info("Executing SQL: " + sqlStatement); + jdbcTemplate.execute(sqlStatement); + } catch (Exception e) { + log.warn("Caught exception when executing SQL statement."); + log.warn(e); + } + } +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/Event.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/Event.java new file mode 100644 index 00000000..f0b61044 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/Event.java @@ -0,0 +1,61 @@ +package cloud.ambar.creditCardProduct.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "event_store", indexes = { + @Index(name = "event_store_idx_event_aggregate_id_version", columnList = "aggregate_id, aggregate_version", unique = true), + @Index(name = "event_store_idx_event_causation_id", columnList = "causation_id", unique = true), + @Index(name = "event_store_idx_event_correlation_id", columnList = "correlation_id"), + @Index(name = "event_store_idx_occurred_on", columnList = "recorded_on") +}) +public class Event { + @Id + @GeneratedValue(strategy= GenerationType.IDENTITY) + private Long id; + + @Column(name="event_id") + private String eventId; + + @Column(name="aggregate_id") + private String aggregateId; + + @Column(name="causation_id") + private String causationID; + + @Column(name="correlation_id") + private String correlationId; + + @Column(name="aggregate_version") + private long version; + + @Column(name="json_payload") + private String data; + + @Column(name="json_metadata") + private String metadata; + + @Column(name="recorded_on") + private LocalDateTime timeStamp; + + @Column(name="event_name") + private String eventName; +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/EventProjector.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/EventProjector.java new file mode 100644 index 00000000..583fc16e --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/EventProjector.java @@ -0,0 +1,8 @@ +package cloud.ambar.creditCardProduct.events; + +import cloud.ambar.creditCardProduct.data.models.projection.Payload; +import com.fasterxml.jackson.core.JsonProcessingException; + +public interface EventProjector { + public void project(Payload eventPayload) throws JsonProcessingException; +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/ProductActivatedEvent.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/ProductActivatedEvent.java new file mode 100644 index 00000000..32716fba --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/ProductActivatedEvent.java @@ -0,0 +1,15 @@ +package cloud.ambar.creditCardProduct.events; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProductActivatedEvent { + public static final String EVENT_NAME = "CreditCardProduct_Product_ProductActivated"; + private String aggregateId; +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/ProductDeactivatedEvent.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/ProductDeactivatedEvent.java new file mode 100644 index 00000000..f328c564 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/ProductDeactivatedEvent.java @@ -0,0 +1,17 @@ +package cloud.ambar.creditCardProduct.events; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProductDeactivatedEvent { + public static final String EVENT_NAME = "CreditCardProduct_Product_ProductDeactivated"; + + private String aggregateId; + +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/ProductDefinedEvent.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/ProductDefinedEvent.java new file mode 100644 index 00000000..f8a8744b --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/ProductDefinedEvent.java @@ -0,0 +1,27 @@ +package cloud.ambar.creditCardProduct.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ProductDefinedEvent { + public static final String EVENT_NAME = "CreditCardProduct_Product_ProductDefined"; + + // Below are the product details for the event, to be returned as the serialized data of the event + private String name; + private int interestInBasisPoints; + private int annualFeeInCents; + private String paymentCycle; + private int creditLimitInCents; + private int maxBalanceTransferAllowedInCents; + private String reward; + private String cardBackgroundHex; +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/README.md b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/README.md new file mode 100644 index 00000000..25a6cd05 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/events/README.md @@ -0,0 +1 @@ +Todo: Add details about the classes in this dir \ No newline at end of file diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/exceptions/InvalidEventException.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/exceptions/InvalidEventException.java new file mode 100644 index 00000000..f051c87f --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/exceptions/InvalidEventException.java @@ -0,0 +1,10 @@ +package cloud.ambar.creditCardProduct.exceptions; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor +public class InvalidEventException extends RuntimeException { + public InvalidEventException(String msg) { + super(msg); + } +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/exceptions/InvalidPaymentCycleException.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/exceptions/InvalidPaymentCycleException.java new file mode 100644 index 00000000..2bb72908 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/exceptions/InvalidPaymentCycleException.java @@ -0,0 +1,10 @@ +package cloud.ambar.creditCardProduct.exceptions; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor +public class InvalidPaymentCycleException extends RuntimeException { + public InvalidPaymentCycleException(String msg) { + super(msg); + } +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/exceptions/InvalidRewardException.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/exceptions/InvalidRewardException.java new file mode 100644 index 00000000..8ee2a2c3 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/exceptions/InvalidRewardException.java @@ -0,0 +1,10 @@ +package cloud.ambar.creditCardProduct.exceptions; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor +public class InvalidRewardException extends RuntimeException { + public InvalidRewardException(String msg) { + super(msg); + } +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/exceptions/NoSuchProductException.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/exceptions/NoSuchProductException.java new file mode 100644 index 00000000..6aa6b42c --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/exceptions/NoSuchProductException.java @@ -0,0 +1,7 @@ +package cloud.ambar.creditCardProduct.exceptions; + +public class NoSuchProductException extends RuntimeException { + public NoSuchProductException(String msg) { + super(msg); + } +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/projection/ProductProjectorService.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/projection/ProductProjectorService.java new file mode 100644 index 00000000..03fa0da6 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/projection/ProductProjectorService.java @@ -0,0 +1,72 @@ +package cloud.ambar.creditCardProduct.projection; + +import cloud.ambar.creditCardProduct.data.models.projection.Payload; +import cloud.ambar.creditCardProduct.data.mongo.ProjectionRepository; +import cloud.ambar.creditCardProduct.events.EventProjector; +import cloud.ambar.creditCardProduct.events.ProductActivatedEvent; +import cloud.ambar.creditCardProduct.events.ProductDeactivatedEvent; +import cloud.ambar.creditCardProduct.events.ProductDefinedEvent; +import cloud.ambar.creditCardProduct.data.models.projection.Product; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +/** + * Takes aggregates and projects them into a list of products for querying later. + */ +@Service +public class ProductProjectorService implements EventProjector { + private static final Logger log = LogManager.getLogger(ProductProjectorService.class); + + private final ProjectionRepository projectionRepository; + + private final ObjectMapper objectMapper; + + public ProductProjectorService(final ProjectionRepository projectionRepository) { + this.projectionRepository = projectionRepository; + this.objectMapper = new ObjectMapper(); + } + + @Override + public void project(Payload event) throws JsonProcessingException { + final Product product; + switch (event.getEventName()) { + case ProductDefinedEvent.EVENT_NAME -> { + log.info("Handling projection for ProductDefinedEvent"); + product = objectMapper.readValue(event.getData(), Product.class); + product.setId(event.getAggregateId()); + } + case ProductActivatedEvent.EVENT_NAME -> { + log.info("Handling projection for ProductActivatedEvent"); + product = getProductOrThrow(event); + product.setActive(true); + } + case ProductDeactivatedEvent.EVENT_NAME -> { + log.info("Handling projection for ProductDeactivatedEvent"); + product = getProductOrThrow(event); + product.setActive(false); + } + default -> { + log.info("Event is not a ProductEvent, doing nothing..."); + return; + } + } + + projectionRepository.save(product); + } + + private Product getProductOrThrow(Payload event) { + Optional product = projectionRepository.findById(event.getAggregateId()); + if (product.isEmpty()) { + final String msg = "Unable to find Product in projection repository for aggregate: " + event.getAggregateId(); + log.error(msg); + throw new RuntimeException(msg); + } + return product.get(); + } + +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/projection/README.md b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/projection/README.md new file mode 100644 index 00000000..e69de29b diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/query/QueryService.java b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/query/QueryService.java new file mode 100644 index 00000000..9e6b0fb7 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/query/QueryService.java @@ -0,0 +1,22 @@ +package cloud.ambar.creditCardProduct.query; + +import cloud.ambar.creditCardProduct.data.mongo.ProjectionRepository; +import cloud.ambar.creditCardProduct.data.models.projection.Product; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class QueryService { + + private final ProjectionRepository projectionRepository; + + public QueryService(final ProjectionRepository projectionRepository) { + this.projectionRepository = projectionRepository; + } + + public List getAllProductListItems() { + return projectionRepository.findAll(); + } + +} diff --git a/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/query/README.md b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/query/README.md new file mode 100644 index 00000000..25a6cd05 --- /dev/null +++ b/application/java-credit-card-product-service/src/main/java/cloud/ambar/creditCardProduct/query/README.md @@ -0,0 +1 @@ +Todo: Add details about the classes in this dir \ No newline at end of file diff --git a/application/java-credit-card-product-service/src/main/resources/application.properties b/application/java-credit-card-product-service/src/main/resources/application.properties new file mode 100644 index 00000000..dfcb00bd --- /dev/null +++ b/application/java-credit-card-product-service/src/main/resources/application.properties @@ -0,0 +1,11 @@ +spring.application.name=CreditCardProduct +# Configs related to our event store, in this case in postgres +spring.datasource.url=jdbc:postgresql://${EVENT_STORE_HOST}:${EVENT_STORE_PORT}/${EVENT_STORE_DATABASE_NAME} +spring.datasource.username=${EVENT_STORE_USER} +spring.datasource.password=${EVENT_STORE_PASSWORD} +spring.datasource.driver-class-name=org.postgresql.Driver +spring.jpa.hibernate.ddl-auto=none +# Configurations related to projection and reaction data store (mongo) +spring.data.mongodb.uri=mongodb://${MONGODB_PROJECTION_DATABASE_USERNAME}:${MONGODB_PROJECTION_DATABASE_PASSWORD}@${MONGODB_PROJECTION_HOST}:${MONGODB_PROJECTION_PORT},${MONGODB_PROJECTION_HOST}:${MONGODB_PROJECTION_PORT}/${MONGODB_PROJECTION_DATABASE_NAME}?serverSelectionTimeoutMS=10000&connectTimeoutMS=10000&authSource=admin +# Logging +logging.level.org.springframework.web=INFO \ No newline at end of file diff --git a/application/java-credit-card-product-service/target/classes/application.properties b/application/java-credit-card-product-service/target/classes/application.properties new file mode 100644 index 00000000..dfcb00bd --- /dev/null +++ b/application/java-credit-card-product-service/target/classes/application.properties @@ -0,0 +1,11 @@ +spring.application.name=CreditCardProduct +# Configs related to our event store, in this case in postgres +spring.datasource.url=jdbc:postgresql://${EVENT_STORE_HOST}:${EVENT_STORE_PORT}/${EVENT_STORE_DATABASE_NAME} +spring.datasource.username=${EVENT_STORE_USER} +spring.datasource.password=${EVENT_STORE_PASSWORD} +spring.datasource.driver-class-name=org.postgresql.Driver +spring.jpa.hibernate.ddl-auto=none +# Configurations related to projection and reaction data store (mongo) +spring.data.mongodb.uri=mongodb://${MONGODB_PROJECTION_DATABASE_USERNAME}:${MONGODB_PROJECTION_DATABASE_PASSWORD}@${MONGODB_PROJECTION_HOST}:${MONGODB_PROJECTION_PORT},${MONGODB_PROJECTION_HOST}:${MONGODB_PROJECTION_PORT}/${MONGODB_PROJECTION_DATABASE_NAME}?serverSelectionTimeoutMS=10000&connectTimeoutMS=10000&authSource=admin +# Logging +logging.level.org.springframework.web=INFO \ No newline at end of file diff --git a/infrastructure/ambar/production/data_destination_creditcardproduct_product.tf b/infrastructure/ambar/production/data_destination_creditcardproduct_product.tf index b2d1fa45..63149b25 100644 --- a/infrastructure/ambar/production/data_destination_creditcardproduct_product.tf +++ b/infrastructure/ambar/production/data_destination_creditcardproduct_product.tf @@ -1,20 +1,9 @@ -resource "ambar_data_destination" "CreditCardProduct_Authentication_Session" { - filter_ids = [ - ambar_filter.security_all.resource_id, - ] - description = "CreditCardProduct_Authentication_Session" - destination_endpoint = "${var.data_destination_credit_card_product.endpoint_prefix}/api/v1/authentication_all_services/projection/session" - username = "username" - password = "password" -} - - -resource "ambar_data_destination" "CreditCardProduct_Product_ProductListItem" { +resource "ambar_data_destination" "CreditCardProduct_Product" { filter_ids = [ ambar_filter.credit_card_product.resource_id, ] description = "CreditCardProduct_Product_ProductListItem" - destination_endpoint = "${var.data_destination_credit_card_product.endpoint_prefix}/api/v1/credit_card_product/product/projection/product_list_item" + destination_endpoint = "${var.data_destination_credit_card_product.endpoint_prefix}/api/v1/credit_card_product/product/projection" username = "username" password = "password" } diff --git a/infrastructure/production.tf b/infrastructure/production.tf index c16ab590..3957c5d0 100644 --- a/infrastructure/production.tf +++ b/infrastructure/production.tf @@ -42,7 +42,7 @@ module "production_credit_card_product" { pgt_proxy_cert_common_name = local.credentials["pgt_proxy_certificate_common_name"] pgtproxy_cert_in_base64 = local.credentials["pgtproxy_cert_in_base64"] pgtproxy_key_in_base64 = local.credentials["pgtproxy_key_in_base64"] - application_directory_name = "backend-monorepo-multiservice" + application_directory_name = "java-credit-card-product-service" full_service_name_in_lowercase = "credit_card_product" providers = {