From 41c28edacde9968b37e428ba34c0901cb03c2ed2 Mon Sep 17 00:00:00 2001 From: Rustam Galikhanov Date: Fri, 4 Oct 2024 01:25:06 +0500 Subject: [PATCH] feature/#42 Added exists options for BucketSettings --- CHANGELOG.md | 1 + .../store/reduct/client/ReductClient.java | 61 ++++++++---- .../store/reduct/model/bucket/Bucket.java | 36 +++++--- .../reduct/model/bucket/BucketSettings.java | 21 +++++ .../store/reduct/client/ReductClientTest.java | 92 +++++++++++++++++++ 5 files changed, 178 insertions(+), 33 deletions(-) create mode 100644 src/test/java/store/reduct/client/ReductClientTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 022a3dd..a04e573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - EntryClient.getRecord(Bucket bucket, Entry body) method to get a record from a bucket [PR-27](https://github.com/reductstore/reduct-java/issues/27) - Improve ReductClient initialization. [PR-41](https://github.com/reductstore/reduct-java/issues/41) +- Add exists option to BucketSettings. [PR-42](https://github.com/reductstore/reduct-java/issues/42) ### Infrastructure: - Added GitHub Actions for CI/CD [PR-35](https://github.com/reductstore/reduct-java/pull/35) diff --git a/src/main/java/store/reduct/client/ReductClient.java b/src/main/java/store/reduct/client/ReductClient.java index b3ac31d..dc671e2 100644 --- a/src/main/java/store/reduct/client/ReductClient.java +++ b/src/main/java/store/reduct/client/ReductClient.java @@ -32,22 +32,26 @@ @Getter public class ReductClient { private static final String REDUCT_ERROR_HEADER = "x-reduct-error"; + public static final String S_S = "%s/%s"; private final ServerProperties serverProperties; private final HttpClient httpClient; private final ObjectMapper objectMapper = new ObjectMapper(); + /** + * Send http query and return response. Throw a ReductException if getting an + * IOException or an InterruptedException + * + * @param builder + * @param bodyHandler + * @return + * @param + */ public HttpResponse send(HttpRequest.Builder builder, HttpResponse.BodyHandler bodyHandler) { try { if (isNotBlank(serverProperties.apiToken())) { builder.headers("Authorization", "Bearer %s".formatted(serverProperties.apiToken())); } - HttpResponse httpResponse = httpClient.send(builder.build(), bodyHandler); - if (httpResponse.statusCode() == 200) { - return httpResponse; - } - throw new ReductException( - httpResponse.headers().firstValue(REDUCT_ERROR_HEADER).orElse("Unsuccessful request"), - httpResponse.statusCode()); + return httpClient.send(builder.build(), bodyHandler); } catch (IOException e) { throw new ReductException("An error occurred while processing the request", e); } catch (InterruptedException e) { @@ -56,6 +60,24 @@ public HttpResponse send(HttpRequest.Builder builder, HttpResponse.BodyHa } } + /** + * Send http query and return response with 200 status only + * + * @param builder + * @param bodyHandler + * @return + * @param + */ + public HttpResponse sendAndGetOnlySuccess(HttpRequest.Builder builder, + HttpResponse.BodyHandler bodyHandler) { + HttpResponse httpResponse = this.send(builder, bodyHandler); + if (httpResponse.statusCode() == 200) { + return httpResponse; + } + throw new ReductException(httpResponse.headers().firstValue(REDUCT_ERROR_HEADER).orElse("Unsuccessful request"), + httpResponse.statusCode()); + } + /** * Create a new bucket with the name and the settings. NOTE: If, authentication * is enabled on the server, an access token with full access must be provided @@ -76,7 +98,7 @@ public HttpResponse send(HttpRequest.Builder builder, HttpResponse.BodyHa public Bucket createBucket(String name, BucketSettings bucketSettings) throws ReductException, IllegalArgumentException { String createBucketPath = BucketURL.CREATE_BUCKET.getUrl().formatted(name); - URI uri = URI.create("%s/%s".formatted(serverProperties.url(), createBucketPath)); + URI uri = URI.create(S_S.formatted(serverProperties.url(), createBucketPath)); HttpRequest.Builder httpRequest = HttpRequest.newBuilder().uri(uri) .POST(HttpRequest.BodyPublishers.ofString(JsonUtils.serialize(bucketSettings))); HttpResponse httpResponse = send(httpRequest, HttpResponse.BodyHandlers.discarding());// TODO ask about @@ -92,7 +114,8 @@ public Bucket createBucket(String name, BucketSettings bucketSettings) // settings as null // until invoke // read. - if (httpResponse.statusCode() == 200) { + if (httpResponse.statusCode() == 200 + || (Boolean.TRUE.equals(bucketSettings.getExists() && httpResponse.statusCode() == 409))) { Bucket bucket = new Bucket(name, this); bucket.setBucketSettings(bucketSettings); return bucket; @@ -116,9 +139,9 @@ public Bucket createBucket(String name, BucketSettings bucketSettings) * status code to indicate the failure. */ public ServerInfo info() throws ReductException { - URI uri = URI.create("%s/%s".formatted(getServerProperties().url(), ServerURL.SERVER_INFO.getUrl())); + URI uri = URI.create(S_S.formatted(getServerProperties().url(), ServerURL.SERVER_INFO.getUrl())); HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri).GET(); - HttpResponse httpResponse = send(builder, HttpResponse.BodyHandlers.ofString()); + HttpResponse httpResponse = sendAndGetOnlySuccess(builder, HttpResponse.BodyHandlers.ofString()); return JsonUtils.parseObject(httpResponse.body(), ServerInfo.class); } @@ -132,9 +155,9 @@ public ServerInfo info() throws ReductException { * status code to indicate the failure. */ public Buckets list() throws ReductException { - URI uri = URI.create("%s/%s".formatted(getServerProperties().url(), ServerURL.LIST.getUrl())); + URI uri = URI.create(S_S.formatted(getServerProperties().url(), ServerURL.LIST.getUrl())); HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri).GET(); - HttpResponse httpResponse = send(builder, HttpResponse.BodyHandlers.ofString()); + HttpResponse httpResponse = sendAndGetOnlySuccess(builder, HttpResponse.BodyHandlers.ofString()); return JsonUtils.parseObject(httpResponse.body(), Buckets.class); } @@ -148,10 +171,10 @@ public Buckets list() throws ReductException { * status code to indicate the failure. */ public Boolean isAlive() throws ReductException { - URI uri = URI.create("%s/%s".formatted(getServerProperties().url(), ServerURL.ALIVE.getUrl())); + URI uri = URI.create(S_S.formatted(getServerProperties().url(), ServerURL.ALIVE.getUrl())); HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri).method("HEAD", HttpRequest.BodyPublishers.noBody()); - HttpResponse httpResponse = send(builder, HttpResponse.BodyHandlers.ofString()); + HttpResponse httpResponse = sendAndGetOnlySuccess(builder, HttpResponse.BodyHandlers.ofString()); return HttpStatus.OK.getCode().equals(httpResponse.statusCode()); } @@ -162,9 +185,9 @@ public Boolean isAlive() throws ReductException { * @return AccessTokens */ public AccessTokens tokens() throws ReductException { - URI uri = URI.create("%s/%s".formatted(getServerProperties().url(), TokenURL.GET_TOKENS.getUrl())); + URI uri = URI.create(S_S.formatted(getServerProperties().url(), TokenURL.GET_TOKENS.getUrl())); HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri).GET(); - HttpResponse httpResponse = send(builder, HttpResponse.BodyHandlers.ofString()); + HttpResponse httpResponse = sendAndGetOnlySuccess(builder, HttpResponse.BodyHandlers.ofString()); return JsonUtils.parseObject(httpResponse.body(), AccessTokens.class); } @@ -201,9 +224,9 @@ public AccessToken createToken(String tokenName, TokenPermissions permissions) String createTokenBody = JsonUtils.serialize(permissions); HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(URI.create("%s/%s".formatted(serverProperties.url(), createTokenPath))) + .uri(URI.create(S_S.formatted(serverProperties.url(), createTokenPath))) .POST(HttpRequest.BodyPublishers.ofString(createTokenBody)); - HttpResponse response = send(builder, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = sendAndGetOnlySuccess(builder, HttpResponse.BodyHandlers.ofString()); return JsonUtils.parseObject(response.body(), AccessToken.class); } } diff --git a/src/main/java/store/reduct/model/bucket/Bucket.java b/src/main/java/store/reduct/model/bucket/Bucket.java index c5b7a90..be90f47 100644 --- a/src/main/java/store/reduct/model/bucket/Bucket.java +++ b/src/main/java/store/reduct/model/bucket/Bucket.java @@ -105,7 +105,8 @@ public Bucket read() throws ReductException, IllegalArgumentException { String createBucketPath = BucketURL.GET_BUCKET.getUrl().formatted(name); HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create("%s/%s".formatted(reductClient.getServerProperties().url(), createBucketPath))).GET(); - HttpResponse httpResponse = reductClient.send(builder, HttpResponse.BodyHandlers.ofString()); + HttpResponse httpResponse = reductClient.sendAndGetOnlySuccess(builder, + HttpResponse.BodyHandlers.ofString()); BucketMapper.INSTANCE.copy(this, JsonUtils.parseObject(httpResponse.body(), Bucket.class)); return this; } @@ -134,11 +135,13 @@ public void setSettings(BucketSettings bucketSettings) throws ReductException, I URI uri = URI.create("%s/%s".formatted(reductClient.getServerProperties().url(), createBucketPath)); HttpRequest.Builder httpRequest = HttpRequest.newBuilder().uri(uri) .PUT(HttpRequest.BodyPublishers.ofString(JsonUtils.serialize(bucketSettings))); - reductClient.send(httpRequest, HttpResponse.BodyHandlers.discarding()); // TODO ask about default settings. The - // answer from DB always is empty for - // success, but settings sets as - // default. This bucket will always have - // settings as null until invoke read. + reductClient.sendAndGetOnlySuccess(httpRequest, HttpResponse.BodyHandlers.discarding()); // TODO ask about + // default settings. + // The + // answer from DB always is empty for + // success, but settings sets as + // default. This bucket will always have + // settings as null until invoke read. } /** @@ -174,7 +177,7 @@ public void writeRecord(@NonNull Record record) throws ReductException, IllegalA HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri).header(getContentTypeHeader(), record.getType()) .POST(HttpRequest.BodyPublishers.ofByteArray(record.getBody())); - reductClient.send(builder, HttpResponse.BodyHandlers.ofString()).body(); + reductClient.sendAndGetOnlySuccess(builder, HttpResponse.BodyHandlers.ofString()).body(); } /** @@ -205,7 +208,7 @@ public void writeRecords(String entryName, Iterator records) } if (Objects.nonNull(body)) { builder.POST(HttpRequest.BodyPublishers.ofByteArray(body)); - reductClient.send(builder, HttpResponse.BodyHandlers.ofString()).body(); + reductClient.sendAndGetOnlySuccess(builder, HttpResponse.BodyHandlers.ofString()).body(); } } @@ -226,7 +229,8 @@ public Record readRecord(String entryName, Long timestamp) throws ReductExceptio URI uri = URI.create(reductClient.getServerProperties().url() + String.format(RecordURL.GET_ENTRY.getUrl(), name, entryName) + timeStampQuery); HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri).GET(); - HttpResponse httpResponse = reductClient.send(builder, HttpResponse.BodyHandlers.ofByteArray()); + HttpResponse httpResponse = reductClient.sendAndGetOnlySuccess(builder, + HttpResponse.BodyHandlers.ofByteArray()); return Record.builder().body(httpResponse.body()).entryName(entryName) .timestamp(httpResponse.headers().firstValue(getXReductTimeHeader()).map(Long::parseLong) .orElseThrow(() -> new ReductException(X_REDUCT_TIME_IS_NOT_SUCH_LONG_FORMAT))) @@ -255,7 +259,8 @@ public Record getMetaInfo(String entryName, Long timestamp) throws ReductExcepti + String.format(RecordURL.GET_ENTRY.getUrl(), name, entryName) + timeStampQuery); HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri).method("HEAD", HttpRequest.BodyPublishers.noBody()); - HttpResponse httpResponse = reductClient.send(builder, HttpResponse.BodyHandlers.ofByteArray()); + HttpResponse httpResponse = reductClient.sendAndGetOnlySuccess(builder, + HttpResponse.BodyHandlers.ofByteArray()); return Record.builder().entryName(entryName) .timestamp(httpResponse.headers().firstValue(getXReductTimeHeader()).map(Long::parseLong) .orElseThrow(() -> new ReductException(X_REDUCT_TIME_IS_NOT_SUCH_LONG_FORMAT))) @@ -285,7 +290,8 @@ public Iterator query(String entryName, Long start, Long stop, Long ttl) reductClient.getServerProperties().url() + String.format(RecordURL.QUERY.getUrl(), name, entryName) + new Queries("start", start).add("stop", stop).add("ttl", ttl)); HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri).GET(); - HttpResponse response = reductClient.send(builder, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = reductClient.sendAndGetOnlySuccess(builder, + HttpResponse.BodyHandlers.ofString()); QueryId queryId = JsonUtils.parseObject(response.body(), QueryId.class); return new RecordIterator(name, entryName, queryId.getId(), reductClient.getServerProperties().url()); @@ -301,7 +307,8 @@ public Iterator getMetaInfos(String entryName, Long start, Long stop, Lo reductClient.getServerProperties().url() + String.format(RecordURL.QUERY.getUrl(), name, entryName) + new Queries("start", start).add("stop", stop).add("ttl", ttl)); HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri).GET(); - HttpResponse response = reductClient.send(builder, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = reductClient.sendAndGetOnlySuccess(builder, + HttpResponse.BodyHandlers.ofString()); QueryId queryId = JsonUtils.parseObject(response.body(), QueryId.class); return new MetaInfoIterator(name, entryName, queryId.getId(), reductClient.getServerProperties().url()); @@ -346,7 +353,8 @@ public boolean hasNext() { return true; } if (!isLast()) { - HttpResponse httpResponse = reductClient.send(builder, HttpResponse.BodyHandlers.ofByteArray()); + HttpResponse httpResponse = reductClient.sendAndGetOnlySuccess(builder, + HttpResponse.BodyHandlers.ofByteArray()); if (httpResponse.statusCode() != 204) { body = httpResponse.body(); int offset = 0; @@ -411,7 +419,7 @@ public boolean hasNext() { return true; } if (!isLast()) { - HttpResponse httpResponse = reductClient.send(getBuilder(), + HttpResponse httpResponse = reductClient.sendAndGetOnlySuccess(getBuilder(), HttpResponse.BodyHandlers.ofByteArray()); if (httpResponse.statusCode() != 204) { for (Map.Entry> ent : httpResponse.headers().map().entrySet()) { diff --git a/src/main/java/store/reduct/model/bucket/BucketSettings.java b/src/main/java/store/reduct/model/bucket/BucketSettings.java index 3d115dc..b28e209 100644 --- a/src/main/java/store/reduct/model/bucket/BucketSettings.java +++ b/src/main/java/store/reduct/model/bucket/BucketSettings.java @@ -1,9 +1,13 @@ package store.reduct.model.bucket; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.*; +/** + * Configuration for a bucket + */ @Getter @Setter @NoArgsConstructor @@ -13,17 +17,34 @@ @AllArgsConstructor public class BucketSettings { + /** + * Max block size in bytes + */ @JsonProperty("max_block_size") private Integer maxBlockSize; + /** + * Max number of records in a block + */ @JsonProperty("max_block_records") private Integer maxBlockRecords; + /** + * Quota type + */ @JsonProperty("quota_type") @JsonFormat(shape = JsonFormat.Shape.STRING) private QuotaType quotaType; + /** + * Quota size in bytes + */ @JsonProperty("quota_size") private Integer quotaSize; + /** + * The client raises no exception if the bucket already exists and returns it + */ + @JsonIgnore + private Boolean exists; } diff --git a/src/test/java/store/reduct/client/ReductClientTest.java b/src/test/java/store/reduct/client/ReductClientTest.java new file mode 100644 index 0000000..b86250a --- /dev/null +++ b/src/test/java/store/reduct/client/ReductClientTest.java @@ -0,0 +1,92 @@ +package store.reduct.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; +import java.util.Map; +import org.assertj.core.api.AbstractThrowableAssert; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import store.reduct.client.config.ServerProperties; +import store.reduct.common.exception.ReductException; +import store.reduct.model.bucket.Bucket; +import store.reduct.model.bucket.BucketSettings; +import store.reduct.utils.http.HttpStatus; + +@ExtendWith(MockitoExtension.class) +class ReductClientTest { + + @Nested + class BucketSettingsTest { + @Mock + private HttpClient httpClient; + @Mock + HttpResponse httpResponse; + + @ParameterizedTest(name = "Should return bucket with an expected name, client and settings if exists = {0}") + @CsvSource(value = {"200, null", "200, false"}, nullValues = "null") + void test1(Integer statusCode, Boolean exists) throws IOException, InterruptedException { + // Init + String expectedBucketName = "testBucket"; + BucketSettings expectedBucketSettings = BucketSettings.builder().maxBlockSize(Integer.MAX_VALUE) + .exists(exists).build(); + ReductClient expectedClient = new ReductClient(ServerProperties.builder().url("http://testUrl").build(), + httpClient); + when(httpClient.send(any(), any())).thenReturn(httpResponse); + when(httpResponse.statusCode()).thenReturn(statusCode); + // Act + Bucket testBucket = expectedClient.createBucket(expectedBucketName, expectedBucketSettings); + // Assert + assertThat(testBucket.getBucketSettings()).isEqualTo(expectedBucketSettings); + assertThat(testBucket.getName()).isEqualTo(expectedBucketName); + assertThat(testBucket.getReductClient()).isEqualTo(expectedClient); + } + @ParameterizedTest(name = "Should throw an exception if status = {0}") + @EnumSource(value = HttpStatus.class, mode = EnumSource.Mode.EXCLUDE, names = {"OK", "CONFLICT"}) + void test2(HttpStatus status) throws IOException, InterruptedException { + // Init + String expectedBucketName = "testBucket"; + BucketSettings expectedBucketSettings = BucketSettings.builder().maxBlockSize(Integer.MAX_VALUE) + .exists(true).build(); + ReductClient expectedClient = new ReductClient(ServerProperties.builder().url("http://testUrl").build(), + httpClient); + when(httpClient.send(any(), any())).thenReturn(httpResponse); + when(httpResponse.statusCode()).thenReturn(status.getCode()); + when(httpResponse.headers()).thenReturn(HttpHeaders.of(Map.of(), (l, r) -> true)); + // Act + AbstractThrowableAssert anAssert = assertThatThrownBy( + () -> expectedClient.createBucket(expectedBucketName, expectedBucketSettings)); + // Assert + anAssert.isInstanceOf(ReductException.class); + } + @ParameterizedTest(name = "Should return bucket with an expected name, client and settings if status = {0}") + @EnumSource(value = HttpStatus.class, mode = EnumSource.Mode.INCLUDE, names = {"OK", "CONFLICT"}) + void test3(HttpStatus status) throws IOException, InterruptedException { + // Init + String expectedBucketName = "testBucket"; + BucketSettings expectedBucketSettings = BucketSettings.builder().maxBlockSize(Integer.MAX_VALUE) + .exists(true).build(); + ReductClient expectedClient = new ReductClient(ServerProperties.builder().url("http://testUrl").build(), + httpClient); + when(httpClient.send(any(), any())).thenReturn(httpResponse); + when(httpResponse.statusCode()).thenReturn(status.getCode()); + // Act + Bucket testBucket = expectedClient.createBucket(expectedBucketName, expectedBucketSettings); + // Assert + assertThat(testBucket.getBucketSettings()).isEqualTo(expectedBucketSettings); + assertThat(testBucket.getName()).isEqualTo(expectedBucketName); + assertThat(testBucket.getReductClient()).isEqualTo(expectedClient); + } + } +}