diff --git a/Dockerfile b/Dockerfile index e7440a4..ff10050 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,5 +8,6 @@ RUN addgroup -S spring && adduser -S spring -G spring USER spring:spring EXPOSE 8080 +EXPOSE 9090 ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 42b7452..28305b8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,11 @@ +import com.google.protobuf.gradle.* + plugins { java jacoco id("org.springframework.boot") version "4.0.1" id("io.spring.dependency-management") version "1.1.7" + id("com.google.protobuf") version "0.9.4" } group = "com.devoops" @@ -19,6 +22,8 @@ repositories { mavenCentral() } +val grpcVersion = "1.68.0" + dependencies { // Web and Core implementation("org.springframework.boot:spring-boot-starter-webmvc") @@ -55,6 +60,13 @@ dependencies { annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") + // gRPC Server + implementation("net.devh:grpc-server-spring-boot-starter:3.1.0.RELEASE") + implementation("io.grpc:grpc-protobuf:$grpcVersion") + implementation("io.grpc:grpc-stub:$grpcVersion") + implementation("io.grpc:grpc-netty-shaded:$grpcVersion") + compileOnly("javax.annotation:javax.annotation-api:1.3.2") + // Tracing (Zipkin) implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") implementation("org.springframework.boot:spring-boot-starter-zipkin") @@ -89,3 +101,21 @@ tasks.jacocoTestReport { xml.required = true } } + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:3.25.5" + } + plugins { + id("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" + } + } + generateProtoTasks { + all().forEach { task -> + task.plugins { + id("grpc") + } + } + } +} diff --git a/src/main/java/com/devoops/user/grpc/UserGrpcService.java b/src/main/java/com/devoops/user/grpc/UserGrpcService.java new file mode 100644 index 0000000..292b391 --- /dev/null +++ b/src/main/java/com/devoops/user/grpc/UserGrpcService.java @@ -0,0 +1,69 @@ +package com.devoops.user.grpc; + +import com.devoops.user.entity.User; +import com.devoops.user.grpc.proto.GetUserSummaryRequest; +import com.devoops.user.grpc.proto.GetUserSummaryResponse; +import com.devoops.user.grpc.proto.UserInternalServiceGrpc; +import com.devoops.user.repository.UserRepository; +import io.grpc.stub.StreamObserver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.server.service.GrpcService; + +import java.util.Optional; +import java.util.UUID; + +@GrpcService +@RequiredArgsConstructor +@Slf4j +public class UserGrpcService extends UserInternalServiceGrpc.UserInternalServiceImplBase { + + private final UserRepository userRepository; + + @Override + public void getUserSummary( + GetUserSummaryRequest request, + StreamObserver responseObserver) { + + log.debug("Received GetUserSummary request for userId: {}", request.getUserId()); + + GetUserSummaryResponse response = processRequest(request); + + responseObserver.onNext(response); + responseObserver.onCompleted(); + } + + private GetUserSummaryResponse processRequest(GetUserSummaryRequest request) { + UUID userId; + try { + userId = UUID.fromString(request.getUserId()); + } catch (IllegalArgumentException e) { + log.warn("Invalid user ID format: {}", request.getUserId()); + return buildNotFoundResponse(); + } + + Optional userOpt = userRepository.findById(userId); + if (userOpt.isEmpty()) { + log.debug("User not found: {}", userId); + return buildNotFoundResponse(); + } + + User user = userOpt.get(); + log.debug("Found user: {} {} ({})", user.getFirstName(), user.getLastName(), user.getRole()); + + return GetUserSummaryResponse.newBuilder() + .setFound(true) + .setUserId(user.getId().toString()) + .setEmail(user.getEmail()) + .setFirstName(user.getFirstName()) + .setLastName(user.getLastName()) + .setRole(user.getRole().name()) + .build(); + } + + private GetUserSummaryResponse buildNotFoundResponse() { + return GetUserSummaryResponse.newBuilder() + .setFound(false) + .build(); + } +} diff --git a/src/main/proto/user_internal.proto b/src/main/proto/user_internal.proto new file mode 100644 index 0000000..b50505a --- /dev/null +++ b/src/main/proto/user_internal.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; +package user; + +option java_multiple_files = true; +option java_package = "com.devoops.user.grpc.proto"; + +service UserInternalService { + rpc GetUserSummary(GetUserSummaryRequest) returns (GetUserSummaryResponse); +} + +message GetUserSummaryRequest { + string user_id = 1; +} + +message GetUserSummaryResponse { + bool found = 1; + string user_id = 2; + string email = 3; + string first_name = 4; + string last_name = 5; + string role = 6; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index de860a4..adb2133 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -45,3 +45,6 @@ spring.rabbitmq.virtual-host=${RABBITMQ_VHOST:/} # Queue config rabbitmq.exchange.notification=notification.exchange rabbitmq.routing-key.user-created=user.created + +# gRPC Server +grpc.server.port=${GRPC_PORT:9090} diff --git a/src/test/java/com/devoops/user/grpc/UserGrpcServiceTest.java b/src/test/java/com/devoops/user/grpc/UserGrpcServiceTest.java new file mode 100644 index 0000000..fd16ad3 --- /dev/null +++ b/src/test/java/com/devoops/user/grpc/UserGrpcServiceTest.java @@ -0,0 +1,204 @@ +package com.devoops.user.grpc; + +import com.devoops.user.entity.Role; +import com.devoops.user.entity.User; +import com.devoops.user.grpc.proto.GetUserSummaryRequest; +import com.devoops.user.grpc.proto.GetUserSummaryResponse; +import com.devoops.user.repository.UserRepository; +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserGrpcServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private StreamObserver responseObserver; + + @InjectMocks + private UserGrpcService userGrpcService; + + private User testUser; + private UUID testUserId; + + @BeforeEach + void setUp() { + testUserId = UUID.randomUUID(); + testUser = buildTestUser(); + } + + private User buildTestUser() { + return User.builder() + .id(testUserId) + .username("testuser") + .password("encoded_password") + .email("test@example.com") + .firstName("Test") + .lastName("User") + .residence("Test City") + .role(Role.GUEST) + .build(); + } + + @Nested + @DisplayName("getUserSummary Tests") + class GetUserSummaryTests { + + @Test + @DisplayName("Should return user summary when user exists") + void getUserSummary_WithExistingUser_ReturnsSummary() { + // Given + GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder() + .setUserId(testUserId.toString()) + .build(); + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + + // When + userGrpcService.getUserSummary(request, responseObserver); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(GetUserSummaryResponse.class); + verify(responseObserver).onNext(captor.capture()); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + + GetUserSummaryResponse response = captor.getValue(); + assertThat(response.getFound()).isTrue(); + assertThat(response.getUserId()).isEqualTo(testUserId.toString()); + assertThat(response.getEmail()).isEqualTo("test@example.com"); + assertThat(response.getFirstName()).isEqualTo("Test"); + assertThat(response.getLastName()).isEqualTo("User"); + assertThat(response.getRole()).isEqualTo("GUEST"); + } + + @Test + @DisplayName("Should return not found when user does not exist") + void getUserSummary_WithNonExistingUser_ReturnsNotFound() { + // Given + UUID unknownId = UUID.randomUUID(); + GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder() + .setUserId(unknownId.toString()) + .build(); + when(userRepository.findById(unknownId)).thenReturn(Optional.empty()); + + // When + userGrpcService.getUserSummary(request, responseObserver); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(GetUserSummaryResponse.class); + verify(responseObserver).onNext(captor.capture()); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + + GetUserSummaryResponse response = captor.getValue(); + assertThat(response.getFound()).isFalse(); + assertThat(response.getUserId()).isEmpty(); + assertThat(response.getEmail()).isEmpty(); + } + + @Test + @DisplayName("Should return not found when user ID is invalid UUID") + void getUserSummary_WithInvalidUUID_ReturnsNotFound() { + // Given + GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder() + .setUserId("invalid-uuid") + .build(); + + // When + userGrpcService.getUserSummary(request, responseObserver); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(GetUserSummaryResponse.class); + verify(responseObserver).onNext(captor.capture()); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + + GetUserSummaryResponse response = captor.getValue(); + assertThat(response.getFound()).isFalse(); + + // Repository should not be called for invalid UUID + verify(userRepository, never()).findById(any()); + } + + @Test + @DisplayName("Should return not found when user ID is empty") + void getUserSummary_WithEmptyUserId_ReturnsNotFound() { + // Given + GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder() + .setUserId("") + .build(); + + // When + userGrpcService.getUserSummary(request, responseObserver); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(GetUserSummaryResponse.class); + verify(responseObserver).onNext(captor.capture()); + verify(responseObserver).onCompleted(); + + GetUserSummaryResponse response = captor.getValue(); + assertThat(response.getFound()).isFalse(); + + verify(userRepository, never()).findById(any()); + } + + @Test + @DisplayName("Should return correct role for HOST user") + void getUserSummary_WithHostUser_ReturnsHostRole() { + // Given + testUser.setRole(Role.HOST); + GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder() + .setUserId(testUserId.toString()) + .build(); + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + + // When + userGrpcService.getUserSummary(request, responseObserver); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(GetUserSummaryResponse.class); + verify(responseObserver).onNext(captor.capture()); + + GetUserSummaryResponse response = captor.getValue(); + assertThat(response.getFound()).isTrue(); + assertThat(response.getRole()).isEqualTo("HOST"); + } + + @Test + @DisplayName("Should handle null user ID gracefully") + void getUserSummary_WithNullUserId_ReturnsNotFound() { + // Given - protobuf returns empty string for null + GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder() + .build(); + + // When + userGrpcService.getUserSummary(request, responseObserver); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(GetUserSummaryResponse.class); + verify(responseObserver).onNext(captor.capture()); + verify(responseObserver).onCompleted(); + + GetUserSummaryResponse response = captor.getValue(); + assertThat(response.getFound()).isFalse(); + + verify(userRepository, never()).findById(any()); + } + } +}