Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 60 additions & 14 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
java
jacoco
id("org.springframework.boot") version "4.0.1"
id("io.spring.dependency-management") version "1.1.7"
}
Expand All @@ -19,26 +20,71 @@ repositories {
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter-flyway")
// implementation("org.springframework.boot:spring-boot-starter-security")
// Web and Core
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-validation")

// MongoDB
implementation("org.springframework.boot:spring-boot-starter-data-mongodb")

// RabbitMQ
implementation("org.springframework.boot:spring-boot-starter-amqp")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")

// Email
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")

// Prometheus Metrics
implementation("io.micrometer:micrometer-registry-prometheus")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("net.logstash.logback:logstash-logback-encoder:8.0")
implementation("org.flywaydb:flyway-database-postgresql")
//zipkin(tracing)
implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave")
implementation("org.springframework.boot:spring-boot-starter-zipkin")
implementation("io.micrometer:micrometer-tracing-bridge-brave")
implementation("io.zipkin.reporter2:zipkin-reporter-brave")

runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-flyway-test")
// testImplementation("org.springframework.boot:spring-boot-starter-security-test")

// Lombok
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")

// Null safety annotations
implementation("org.jspecify:jspecify:1.0.0")

// MapStruct
implementation("org.mapstruct:mapstruct:1.6.3")
annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3")
annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0")

// Tracing (Zipkin)
implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave")
implementation("org.springframework.boot:spring-boot-starter-zipkin")
implementation("io.micrometer:micrometer-tracing-bridge-brave")
implementation("io.zipkin.reporter2:zipkin-reporter-brave")

// Logging
implementation("net.logstash.logback:logstash-logback-encoder:8.0")

// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.springframework.amqp:spring-rabbit-test")
testImplementation("org.testcontainers:junit-jupiter:1.20.4")
testImplementation("org.testcontainers:mongodb:1.20.4")
testImplementation("org.testcontainers:rabbitmq:1.20.4")
testImplementation("com.icegreen:greenmail-junit5:2.0.1")
testImplementation("io.rest-assured:rest-assured:5.5.0")
testImplementation("org.awaitility:awaitility:4.2.0")
testCompileOnly("org.projectlombok:lombok")
testAnnotationProcessor("org.projectlombok:lombok")

testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.withType<Test> {
useJUnitPlatform()
finalizedBy(tasks.jacocoTestReport)
}

tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required = true
}
}
26 changes: 25 additions & 1 deletion environment/.local.env
Original file line number Diff line number Diff line change
@@ -1,4 +1,28 @@
SERVER_PORT=8080
LOGSTASH_HOST=logstash:5000
ZIPKIN_HOST=zipkin
ZIPKIN_PORT=9411
ZIPKIN_PORT=9411

# MongoDB
MONGODB_HOST=devoops-mongodb
MONGODB_PORT=27017
MONGODB_USERNAME=devoops
MONGODB_PASSWORD=devoops

# RabbitMQ
RABBITMQ_HOST=devoops-rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_USERNAME=devoops
RABBITMQ_PASSWORD=devoops123
RABBITMQ_VHOST=/

# Email (MailHog for local development)
MAIL_HOST=devoops-mailhog
MAIL_PORT=1025
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM=noreply@devoops.com
MAIL_FROM_NAME=DevOops

# Frontend
FRONTEND_URL=http://localhost:4200
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;

@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
@SpringBootApplication
public class NotificationApplication {

public static void main(String[] args) {
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/com/devoops/notification/config/MongoConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.devoops.notification.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.config.EnableMongoAuditing;

@Configuration
@EnableMongoAuditing
public class MongoConfig {

}
180 changes: 180 additions & 0 deletions src/main/java/com/devoops/notification/config/RabbitMQConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package com.devoops.notification.config;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitMQConfig {

@Value("${rabbitmq.exchange.notification}")
private String notificationExchange;

@Value("${rabbitmq.exchange.dlx}")
private String deadLetterExchange;

@Value("${rabbitmq.queue.dlq}")
private String deadLetterQueue;

@Value("${rabbitmq.queue.reservation-created}")
private String reservationCreatedQueue;

@Value("${rabbitmq.queue.reservation-cancelled}")
private String reservationCancelledQueue;

@Value("${rabbitmq.queue.host-rated}")
private String hostRatedQueue;

@Value("${rabbitmq.queue.accommodation-rated}")
private String accommodationRatedQueue;

@Value("${rabbitmq.queue.reservation-response}")
private String reservationResponseQueue;

@Value("${rabbitmq.queue.user-created}")
private String userCreatedQueue;

@Bean
public MessageConverter jsonMessageConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return new Jackson2JsonMessageConverter(objectMapper);
}

@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
rabbitTemplate.setMessageConverter(jsonMessageConverter());
return rabbitTemplate;
}

@Bean
public TopicExchange notificationExchange() {
return new TopicExchange(notificationExchange);
}

@Bean
public DirectExchange deadLetterExchange() {
return new DirectExchange(deadLetterExchange);
}

@Bean
public Queue deadLetterQueue() {
return QueueBuilder.durable(deadLetterQueue).build();
}

@Bean
public Binding deadLetterBinding() {
return BindingBuilder
.bind(deadLetterQueue())
.to(deadLetterExchange())
.with("notification.dlq");
}

@Bean
public Queue reservationCreatedQueue() {
return QueueBuilder.durable(reservationCreatedQueue)
.withArgument("x-dead-letter-exchange", deadLetterExchange)
.withArgument("x-dead-letter-routing-key", "notification.dlq")
.build();
}

@Bean
public Queue reservationCancelledQueue() {
return QueueBuilder.durable(reservationCancelledQueue)
.withArgument("x-dead-letter-exchange", deadLetterExchange)
.withArgument("x-dead-letter-routing-key", "notification.dlq")
.build();
}

@Bean
public Queue hostRatedQueue() {
return QueueBuilder.durable(hostRatedQueue)
.withArgument("x-dead-letter-exchange", deadLetterExchange)
.withArgument("x-dead-letter-routing-key", "notification.dlq")
.build();
}

@Bean
public Queue accommodationRatedQueue() {
return QueueBuilder.durable(accommodationRatedQueue)
.withArgument("x-dead-letter-exchange", deadLetterExchange)
.withArgument("x-dead-letter-routing-key", "notification.dlq")
.build();
}

@Bean
public Queue reservationResponseQueue() {
return QueueBuilder.durable(reservationResponseQueue)
.withArgument("x-dead-letter-exchange", deadLetterExchange)
.withArgument("x-dead-letter-routing-key", "notification.dlq")
.build();
}

@Bean
public Binding reservationCreatedBinding() {
return BindingBuilder
.bind(reservationCreatedQueue())
.to(notificationExchange())
.with("notification.reservation.created");
}

@Bean
public Binding reservationCancelledBinding() {
return BindingBuilder
.bind(reservationCancelledQueue())
.to(notificationExchange())
.with("notification.reservation.cancelled");
}

@Bean
public Binding hostRatedBinding() {
return BindingBuilder
.bind(hostRatedQueue())
.to(notificationExchange())
.with("notification.rating.host");
}

@Bean
public Binding accommodationRatedBinding() {
return BindingBuilder
.bind(accommodationRatedQueue())
.to(notificationExchange())
.with("notification.rating.accommodation");
}

@Bean
public Binding reservationResponseBinding() {
return BindingBuilder
.bind(reservationResponseQueue())
.to(notificationExchange())
.with("notification.reservation.response");
}

@Bean
public Queue userCreatedQueue() {
return QueueBuilder.durable(userCreatedQueue)
.withArgument("x-dead-letter-exchange", deadLetterExchange)
.withArgument("x-dead-letter-routing-key", "notification.dlq")
.build();
}

@Bean
public Binding userCreatedBinding() {
return BindingBuilder
.bind(userCreatedQueue())
.to(notificationExchange())
.with("user.created");
}
}
12 changes: 12 additions & 0 deletions src/main/java/com/devoops/notification/config/RequireRole.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.devoops.notification.config;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
String[] value();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.devoops.notification.config;

import com.devoops.notification.exception.ForbiddenException;
import com.devoops.notification.exception.UnauthorizedException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import java.util.Arrays;

@Component
public class RoleAuthorizationInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull Object handler
) {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}

RequireRole methodAnnotation = handlerMethod.getMethodAnnotation(RequireRole.class);
RequireRole classAnnotation = handlerMethod.getBeanType().getAnnotation(RequireRole.class);

RequireRole requireRole = methodAnnotation != null ? methodAnnotation : classAnnotation;
if (requireRole == null) {
return true;
}

String role = request.getHeader("X-User-Role");
if (role == null) {
throw new UnauthorizedException("Missing authentication headers");
}

boolean hasRole = Arrays.stream(requireRole.value())
.anyMatch(r -> r.equalsIgnoreCase(role));

if (!hasRole) {
throw new ForbiddenException("Insufficient permissions");
}

return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.devoops.notification.config;

import java.util.UUID;

public record UserContext(UUID userId, String role) { }
Loading