diff --git a/app/build.gradle b/app/build.gradle index 8791b2a..eb110ba 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,6 +4,7 @@ buildscript { ext.protobufVersion = '3.21.7' ext.jerseyVersion = '3.1.0' ext.junitVersion = '5.9.0' + ext.mockitoVersion = '5.2.0' ext.postgresVersion = '42.5.1' ext.jooqVersion = '3.17.7' ext.guiceVersion = '5.1.0' @@ -33,6 +34,8 @@ version '1.0' dependencies { implementation "com.google.protobuf:protobuf-java:$protobufVersion" + implementation('org.glassfish.jersey.containers:jersey-container-servlet:3.1.0') + //jOOQ & Postgres impl deps implementation "org.jooq:jooq:$jooqVersion" implementation "org.jooq:jooq-meta:$jooqVersion" @@ -42,7 +45,12 @@ dependencies { implementation "com.google.inject:guice:$guiceVersion" + compileOnly 'org.projectlombok:lombok:1.18.24' + annotationProcessor 'org.projectlombok:lombok:1.18.24' + testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" + testImplementation "org.junit.jupiter:junit-jupiter-params:$junitVersion" + testImplementation "org.mockito:mockito-core:$mockitoVersion" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" testImplementation "org.hamcrest:hamcrest-library:2.2" testImplementation "org.testcontainers:junit-jupiter:1.17.6" diff --git a/app/src/main/java/org/vss/VSSApplication.java b/app/src/main/java/org/vss/VSSApplication.java new file mode 100644 index 0000000..0080669 --- /dev/null +++ b/app/src/main/java/org/vss/VSSApplication.java @@ -0,0 +1,10 @@ +package org.vss; + +import jakarta.ws.rs.ApplicationPath; +import org.glassfish.jersey.server.ResourceConfig; + +@ApplicationPath("/") +public class VSSApplication extends ResourceConfig { + public VSSApplication() { + } +} diff --git a/app/src/main/java/org/vss/api/AbstractVssApi.java b/app/src/main/java/org/vss/api/AbstractVssApi.java new file mode 100644 index 0000000..e8a79eb --- /dev/null +++ b/app/src/main/java/org/vss/api/AbstractVssApi.java @@ -0,0 +1,48 @@ +package org.vss.api; + +import com.google.protobuf.GeneratedMessageV3; +import com.google.protobuf.InvalidProtocolBufferException; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; +import org.vss.ErrorCode; +import org.vss.ErrorResponse; +import org.vss.KVStore; +import org.vss.exception.ConflictException; + +public abstract class AbstractVssApi { + final KVStore kvStore; + + @Inject + public AbstractVssApi(KVStore kvStore) { + this.kvStore = kvStore; + } + + Response toResponse(GeneratedMessageV3 protoResponse) { + + return Response + .status(Response.Status.OK) + .entity(protoResponse.toByteArray()) + .build(); + } + + Response toErrorResponse(Exception e) { + ErrorCode errorCode; + if (e instanceof ConflictException) { + errorCode = ErrorCode.CONFLICT_EXCEPTION; + } else if (e instanceof IllegalArgumentException + || e instanceof InvalidProtocolBufferException) { + errorCode = ErrorCode.INVALID_REQUEST_EXCEPTION; + } else { + errorCode = ErrorCode.INTERNAL_SERVER_EXCEPTION; + } + + ErrorResponse errorResponse = ErrorResponse.newBuilder() + .setErrorCode(errorCode) + .setMessage(e.getMessage()) + .build(); + + return Response.status(errorCode.getNumber()) + .entity(errorResponse.toByteArray()) + .build(); + } +} diff --git a/app/src/main/java/org/vss/api/GetObjectApi.java b/app/src/main/java/org/vss/api/GetObjectApi.java new file mode 100644 index 0000000..9e0a358 --- /dev/null +++ b/app/src/main/java/org/vss/api/GetObjectApi.java @@ -0,0 +1,35 @@ +package org.vss.api; + +import jakarta.inject.Inject; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.extern.slf4j.Slf4j; +import org.vss.GetObjectRequest; +import org.vss.GetObjectResponse; +import org.vss.KVStore; + +@Path(VssApiEndpoint.GET_OBJECT) +@Slf4j +public class GetObjectApi extends AbstractVssApi { + + @Inject + public GetObjectApi(KVStore kvstore) { + super(kvstore); + } + + @POST + @Produces(MediaType.APPLICATION_OCTET_STREAM) + public Response execute(byte[] payload) { + try { + GetObjectRequest request = GetObjectRequest.parseFrom(payload); + GetObjectResponse response = kvStore.get(request); + return toResponse(response); + } catch (Exception e) { + log.error("Exception in GetObjectApi: ", e); + return toErrorResponse(e); + } + } +} diff --git a/app/src/main/java/org/vss/api/ListKeyVersionsApi.java b/app/src/main/java/org/vss/api/ListKeyVersionsApi.java new file mode 100644 index 0000000..0ce2237 --- /dev/null +++ b/app/src/main/java/org/vss/api/ListKeyVersionsApi.java @@ -0,0 +1,35 @@ +package org.vss.api; + +import jakarta.inject.Inject; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.extern.slf4j.Slf4j; +import org.vss.KVStore; +import org.vss.ListKeyVersionsRequest; +import org.vss.ListKeyVersionsResponse; + +@Path(VssApiEndpoint.LIST_KEY_VERSIONS) +@Slf4j +public class ListKeyVersionsApi extends AbstractVssApi { + + @Inject + public ListKeyVersionsApi(KVStore kvStore) { + super(kvStore); + } + + @POST + @Produces(MediaType.APPLICATION_OCTET_STREAM) + public Response execute(byte[] payload) { + try { + ListKeyVersionsRequest request = ListKeyVersionsRequest.parseFrom(payload); + ListKeyVersionsResponse response = kvStore.listKeyVersions(request); + return toResponse(response); + } catch (Exception e) { + log.error("Exception in ListKeyVersionsApi: ", e); + return toErrorResponse(e); + } + } +} diff --git a/app/src/main/java/org/vss/api/PutObjectsApi.java b/app/src/main/java/org/vss/api/PutObjectsApi.java new file mode 100644 index 0000000..ab3689f --- /dev/null +++ b/app/src/main/java/org/vss/api/PutObjectsApi.java @@ -0,0 +1,35 @@ +package org.vss.api; + +import jakarta.inject.Inject; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.extern.slf4j.Slf4j; +import org.vss.KVStore; +import org.vss.PutObjectRequest; +import org.vss.PutObjectResponse; + +@Path(VssApiEndpoint.PUT_OBJECTS) +@Slf4j +public class PutObjectsApi extends AbstractVssApi { + + @Inject + public PutObjectsApi(KVStore kvStore) { + super(kvStore); + } + + @POST + @Produces(MediaType.APPLICATION_OCTET_STREAM) + public Response execute(byte[] payload) { + try { + PutObjectRequest putObjectRequest = PutObjectRequest.parseFrom(payload); + PutObjectResponse response = kvStore.put(putObjectRequest); + return toResponse(response); + } catch (Exception e) { + log.error("Exception in PutObjectsApi: ", e); + return toErrorResponse(e); + } + } +} diff --git a/app/src/main/java/org/vss/api/VssApiEndpoint.java b/app/src/main/java/org/vss/api/VssApiEndpoint.java new file mode 100644 index 0000000..e8c6ab4 --- /dev/null +++ b/app/src/main/java/org/vss/api/VssApiEndpoint.java @@ -0,0 +1,7 @@ +package org.vss.api; + +public class VssApiEndpoint { + public static final String GET_OBJECT = "/getObject"; + public static final String PUT_OBJECTS = "/putObjects"; + public static final String LIST_KEY_VERSIONS = "/listKeyVersions"; +} diff --git a/app/src/test/java/org/vss/api/GetObjectApiTest.java b/app/src/test/java/org/vss/api/GetObjectApiTest.java new file mode 100644 index 0000000..a4edfe0 --- /dev/null +++ b/app/src/test/java/org/vss/api/GetObjectApiTest.java @@ -0,0 +1,86 @@ +package org.vss.api; + +import com.google.protobuf.ByteString; +import jakarta.ws.rs.core.Response; +import java.nio.charset.StandardCharsets; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.vss.ErrorCode; +import org.vss.ErrorResponse; +import org.vss.GetObjectRequest; +import org.vss.GetObjectResponse; +import org.vss.KVStore; +import org.vss.KeyValue; +import org.vss.exception.ConflictException; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class GetObjectApiTest { + private GetObjectApi getObjectApi; + private KVStore mockKVStore; + + private static String TEST_STORE_ID = "storeId"; + private static String TEST_KEY = "key"; + private static KeyValue TEST_KV = KeyValue.newBuilder().setKey(TEST_KEY).setValue( + ByteString.copyFrom("test_value", StandardCharsets.UTF_8)).build(); + + @BeforeEach + void setUp() { + mockKVStore = mock(KVStore.class); + getObjectApi = new GetObjectApi(mockKVStore); + } + + @Test + void execute_ValidPayload_ReturnsResponse() { + GetObjectRequest expectedRequest = + GetObjectRequest.newBuilder().setStoreId(TEST_STORE_ID).setKey(TEST_KEY).build(); + byte[] payload = expectedRequest.toByteArray(); + GetObjectResponse mockResponse = GetObjectResponse.newBuilder().setValue(TEST_KV).build(); + when(mockKVStore.get(expectedRequest)).thenReturn(mockResponse); + + Response actualResponse = getObjectApi.execute(payload); + + assertThat(actualResponse.getStatus(), is(Response.Status.OK.getStatusCode())); + assertThat(actualResponse.getEntity(), is(mockResponse.toByteArray())); + verify(mockKVStore).get(expectedRequest); + } + + @ParameterizedTest + @MethodSource("provideErrorTestCases") + void execute_InvalidPayload_ReturnsErrorResponse(Exception exception, + ErrorCode errorCode) { + GetObjectRequest expectedRequest = GetObjectRequest.newBuilder() + .setStoreId(TEST_STORE_ID) + .setKey(TEST_KEY) + .build(); + byte[] payload = expectedRequest.toByteArray(); + when(mockKVStore.get(any())).thenThrow(exception); + + Response response = getObjectApi.execute(payload); + + ErrorResponse expectedErrorResponse = ErrorResponse.newBuilder() + .setErrorCode(errorCode) + .setMessage("") + .build(); + assertThat(response.getEntity(), is(expectedErrorResponse.toByteArray())); + assertThat(response.getStatus(), is(expectedErrorResponse.getErrorCode().getNumber())); + verify(mockKVStore).get(expectedRequest); + } + + private static Stream provideErrorTestCases() { + return Stream.of( + Arguments.of(new ConflictException(""), ErrorCode.CONFLICT_EXCEPTION), + Arguments.of(new IllegalArgumentException(""), ErrorCode.INVALID_REQUEST_EXCEPTION), + Arguments.of(new RuntimeException(""), ErrorCode.INTERNAL_SERVER_EXCEPTION) + ); + } +} diff --git a/app/src/test/java/org/vss/api/ListKeyVersionsApiTest.java b/app/src/test/java/org/vss/api/ListKeyVersionsApiTest.java new file mode 100644 index 0000000..6359ed0 --- /dev/null +++ b/app/src/test/java/org/vss/api/ListKeyVersionsApiTest.java @@ -0,0 +1,92 @@ +package org.vss.api; + +import com.google.protobuf.ByteString; +import jakarta.ws.rs.core.Response; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.vss.ErrorCode; +import org.vss.ErrorResponse; +import org.vss.KVStore; +import org.vss.KeyValue; +import org.vss.ListKeyVersionsRequest; +import org.vss.ListKeyVersionsResponse; +import org.vss.exception.ConflictException; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ListKeyVersionsApiTest { + private ListKeyVersionsApi listKeyVersionsApi; + private KVStore mockKVStore; + + private static String TEST_STORE_ID = "storeId"; + private static String TEST_KEY = "key"; + private static KeyValue TEST_KV = KeyValue.newBuilder().setKey(TEST_KEY).setValue( + ByteString.copyFrom("test_value", StandardCharsets.UTF_8)).build(); + + @BeforeEach + void setUp() { + mockKVStore = mock(KVStore.class); + listKeyVersionsApi = new ListKeyVersionsApi(mockKVStore); + } + + @Test + void execute_ValidPayload_ReturnsResponse() { + ListKeyVersionsRequest expectedRequest = + ListKeyVersionsRequest.newBuilder() + .setStoreId(TEST_STORE_ID) + .setKeyPrefix(TEST_KEY) + .build(); + byte[] payload = expectedRequest.toByteArray(); + ListKeyVersionsResponse mockResponse = ListKeyVersionsResponse.newBuilder().addAllKeyVersions( + List.of(TEST_KV)).build(); + when(mockKVStore.listKeyVersions(expectedRequest)).thenReturn(mockResponse); + + Response actualResponse = listKeyVersionsApi.execute(payload); + + assertThat(actualResponse.getStatus(), is(Response.Status.OK.getStatusCode())); + assertThat(actualResponse.getEntity(), is(mockResponse.toByteArray())); + verify(mockKVStore).listKeyVersions(expectedRequest); + } + + @ParameterizedTest + @MethodSource("provideErrorTestCases") + void execute_InvalidPayload_ReturnsErrorResponse(Exception exception, + ErrorCode errorCode) { + ListKeyVersionsRequest expectedRequest = + ListKeyVersionsRequest.newBuilder() + .setStoreId(TEST_STORE_ID) + .setKeyPrefix(TEST_KEY) + .build(); + byte[] payload = expectedRequest.toByteArray(); + when(mockKVStore.listKeyVersions(any())).thenThrow(exception); + + Response response = listKeyVersionsApi.execute(payload); + + ErrorResponse expectedErrorResponse = ErrorResponse.newBuilder() + .setErrorCode(errorCode) + .setMessage("") + .build(); + assertThat(response.getEntity(), is(expectedErrorResponse.toByteArray())); + assertThat(response.getStatus(), is(expectedErrorResponse.getErrorCode().getNumber())); + verify(mockKVStore).listKeyVersions(expectedRequest); + } + + private static Stream provideErrorTestCases() { + return Stream.of( + Arguments.of(new ConflictException(""), ErrorCode.CONFLICT_EXCEPTION), + Arguments.of(new IllegalArgumentException(""), ErrorCode.INVALID_REQUEST_EXCEPTION), + Arguments.of(new RuntimeException(""), ErrorCode.INTERNAL_SERVER_EXCEPTION) + ); + } +} diff --git a/app/src/test/java/org/vss/api/PutObjectsApiTest.java b/app/src/test/java/org/vss/api/PutObjectsApiTest.java new file mode 100644 index 0000000..d816bb2 --- /dev/null +++ b/app/src/test/java/org/vss/api/PutObjectsApiTest.java @@ -0,0 +1,91 @@ +package org.vss.api; + +import com.google.protobuf.ByteString; +import jakarta.ws.rs.core.Response; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.vss.ErrorCode; +import org.vss.ErrorResponse; +import org.vss.KVStore; +import org.vss.KeyValue; +import org.vss.PutObjectRequest; +import org.vss.PutObjectResponse; +import org.vss.exception.ConflictException; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class PutObjectsApiTest { + private PutObjectsApi putObjectsApi; + private KVStore mockKVStore; + + private static String TEST_STORE_ID = "storeId"; + private static String TEST_KEY = "key"; + private static KeyValue TEST_KV = KeyValue.newBuilder().setKey(TEST_KEY).setValue( + ByteString.copyFrom("test_value", StandardCharsets.UTF_8)).build(); + + @BeforeEach + void setUp() { + mockKVStore = mock(KVStore.class); + putObjectsApi = new PutObjectsApi(mockKVStore); + } + + @Test + void execute_ValidPayload_ReturnsResponse() { + PutObjectRequest expectedRequest = + PutObjectRequest.newBuilder() + .setStoreId(TEST_STORE_ID) + .addAllTransactionItems(List.of(TEST_KV)) + .build(); + byte[] payload = expectedRequest.toByteArray(); + PutObjectResponse mockResponse = PutObjectResponse.newBuilder().build(); + when(mockKVStore.put(expectedRequest)).thenReturn(mockResponse); + + Response actualResponse = putObjectsApi.execute(payload); + + assertThat(actualResponse.getStatus(), is(Response.Status.OK.getStatusCode())); + assertThat(actualResponse.getEntity(), is(mockResponse.toByteArray())); + verify(mockKVStore).put(expectedRequest); + } + + @ParameterizedTest + @MethodSource("provideErrorTestCases") + void execute_InvalidPayload_ReturnsErrorResponse(Exception exception, + ErrorCode errorCode) { + PutObjectRequest expectedRequest = + PutObjectRequest.newBuilder() + .setStoreId(TEST_STORE_ID) + .addAllTransactionItems(List.of(TEST_KV)) + .build(); + byte[] payload = expectedRequest.toByteArray(); + when(mockKVStore.put(any())).thenThrow(exception); + + Response response = putObjectsApi.execute(payload); + + ErrorResponse expectedErrorResponse = ErrorResponse.newBuilder() + .setErrorCode(errorCode) + .setMessage("") + .build(); + assertThat(response.getEntity(), is(expectedErrorResponse.toByteArray())); + assertThat(response.getStatus(), is(expectedErrorResponse.getErrorCode().getNumber())); + verify(mockKVStore).put(expectedRequest); + } + + private static Stream provideErrorTestCases() { + return Stream.of( + Arguments.of(new ConflictException(""), ErrorCode.CONFLICT_EXCEPTION), + Arguments.of(new IllegalArgumentException(""), ErrorCode.INVALID_REQUEST_EXCEPTION), + Arguments.of(new RuntimeException(""), ErrorCode.INTERNAL_SERVER_EXCEPTION) + ); + } +}