diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index a98264c1577..27ffe7ba0dc 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -23,21 +23,21 @@ jobs: distribution: temurin java-version: 17 - name: Cache Maven Dependencies (~/.m2/repository) - uses: actions/cache@v3.0.11 + uses: actions/cache@v3.2.6 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: | ${{ runner.os }}-maven-: - name: Cache NPM Dependencies (core/com.b2international.snowowl.core.rest/snow-owl-api-docs/node_modules) - uses: actions/cache@v3.0.11 + uses: actions/cache@v3.2.6 with: path: core/com.b2international.snowowl.core.rest/snow-owl-api-docs/node_modules key: ${{ runner.os }}-npm-${{ hashFiles('core/com.b2international.snowowl.core.rest/snow-owl-api-docs/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm-: - name: Setup Maven settings.xml - uses: whelk-io/maven-settings-xml-action@v14 + uses: whelk-io/maven-settings-xml-action@v21 with: servers: '[{ "id": "b2i-releases", "username": "${env.NEXUS_DEPLOYMENT_USER}", "password": "${env.NEXUS_DEPLOYMENT_PASS}" }, { "id": "b2i-snapshots", "username": "${env.NEXUS_DEPLOYMENT_USER}", "password": "${env.NEXUS_DEPLOYMENT_PASS}" }, { "id": "nexus_deployment", "username": "${env.NEXUS_DEPLOYMENT_USER}", "password": "${env.NEXUS_DEPLOYMENT_PASS}" }]' # Initializes the CodeQL tools for scanning. diff --git a/core/com.b2international.snowowl.core.rest.tests/src/com/b2international/snowowl/core/rest/AllSnowOwlApiTests.java b/core/com.b2international.snowowl.core.rest.tests/src/com/b2international/snowowl/core/rest/AllSnowOwlApiTests.java index cd4e53a207f..9887c592357 100644 --- a/core/com.b2international.snowowl.core.rest.tests/src/com/b2international/snowowl/core/rest/AllSnowOwlApiTests.java +++ b/core/com.b2international.snowowl.core.rest.tests/src/com/b2international/snowowl/core/rest/AllSnowOwlApiTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2021 B2i Healthcare Pte Ltd, http://b2i.sg + * Copyright 2011-2023 B2i Healthcare Pte Ltd, http://b2i.sg * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import com.b2international.snowowl.core.rest.auth.BasicAuthenticationTest; import com.b2international.snowowl.core.rest.bundle.BundleRestApiTest; import com.b2international.snowowl.core.rest.codesystem.CodeSystemApiTest; +import com.b2international.snowowl.core.rest.rate.RateLimitTest; import com.b2international.snowowl.core.rest.resource.ResourceApiTest; import com.b2international.snowowl.test.commons.BundleStartRule; import com.b2international.snowowl.test.commons.SnowOwlAppRule; @@ -37,10 +38,11 @@ @SuiteClasses({ BasicAuthenticationTest.class, AuthorizationTest.class, + RateLimitTest.class, CodeSystemApiTest.class, ResourceApiTest.class, BundleApiTest.class, - BundleRestApiTest.class + BundleRestApiTest.class, }) public class AllSnowOwlApiTests { diff --git a/core/com.b2international.snowowl.core.rest.tests/src/com/b2international/snowowl/core/rest/rate/RateLimitTest.java b/core/com.b2international.snowowl.core.rest.tests/src/com/b2international/snowowl/core/rest/rate/RateLimitTest.java new file mode 100644 index 00000000000..daca5ebdfc0 --- /dev/null +++ b/core/com.b2international.snowowl.core.rest.tests/src/com/b2international/snowowl/core/rest/rate/RateLimitTest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2023 B2i Healthcare Pte Ltd, http://b2i.sg + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.b2international.snowowl.core.rest.rate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.CoreMatchers.anyOf; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.Assert.fail; + +import java.time.LocalDateTime; + +import org.elasticsearch.core.Map; +import org.junit.After; +import org.junit.Before; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.runners.MethodSorters; + +import com.b2international.commons.exceptions.TooManyRequestsException; +import com.b2international.snowowl.core.ApplicationContext; +import com.b2international.snowowl.core.Resources; +import com.b2international.snowowl.core.events.util.Response; +import com.b2international.snowowl.core.rate.ApiConfiguration; +import com.b2international.snowowl.core.rate.ApiPlugin; +import com.b2international.snowowl.core.rate.RateLimitConsumption; +import com.b2international.snowowl.core.rate.RateLimiter; +import com.b2international.snowowl.core.request.ResourceRequests; +import com.b2international.snowowl.core.setup.Environment; +import com.b2international.snowowl.test.commons.Services; +import com.b2international.snowowl.test.commons.rest.ResourceApiAssert; +import com.b2international.snowowl.test.commons.rest.RestExtensions; + +/** + * @since 8.10 + */ +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class RateLimitTest { + + private RateLimiter rateLimiter; + + @Before + public void setup() throws Exception { + // inject rate limit feature into the system until we run the rate limit tests + Environment env = ApplicationContext.getServiceForClass(Environment.class); + ApiConfiguration apiConfig = new ApiConfiguration(); + // this means that the user is able to perform 2 requests at a time, with one second refill rate (default value, but just in case fix it here as well) + apiConfig.setOverdraft(2L); + apiConfig.setRefillRate(1L); + new ApiPlugin().initRateLimiter(env, apiConfig); + this.rateLimiter = env.service(RateLimiter.class); + + } + + @After + public void after() throws Exception { + // revert back to noop rate limiter + ApplicationContext.getServiceForClass(Environment.class).services().registerService(RateLimiter.class, RateLimiter.NOOP); + rateLimiter = null; + } + + @Test + public void rateLimitServiceTest() throws Exception { + // perform three consumptions at the same time + RateLimitConsumption firstRequest = rateLimiter.consume(RestExtensions.USER); + RateLimitConsumption secondRequest = rateLimiter.consume(RestExtensions.USER); + RateLimitConsumption thirdRequest = rateLimiter.consume(RestExtensions.USER); + // first two should succeed, third should fail to consume token + assertThat(firstRequest.isConsumed()).isTrue(); + assertThat(secondRequest.isConsumed()).isTrue(); + assertThat(thirdRequest.isConsumed()).isFalse(); + } + + @Test + public void rateLimitTest_JavaAPI() throws Exception { + Response response = ResourceRequests.prepareSearch() + .setLimit(0) + .buildAsync() + .execute(Services.bus()) + .getSyncResponse(); + + assertThat(response.getBody()).isNotNull(); + assertThat(response.getHeaders()).containsEntry("X-Rate-Limit-Remaining", "1"); + } + + @Test + public void rateLimitTest_RestAPI() throws Exception { + ResourceApiAssert.assertResourceSearch(Map.of("limit", 0)) + .statusCode(200) + .header("X-Rate-Limit-Remaining", "1"); + } + + @Test + public void rateLimitTest_JavaAPI_RateLimited() throws Exception { + // assume user consumed all available tokens from the bucket + rateLimiter.consume(RestExtensions.USER); + rateLimiter.consume(RestExtensions.USER); + + try { + ResourceRequests.prepareSearch() + .setLimit(0) + .buildAsync() + .execute(Services.bus()) + .getSyncResponse(); + fail("Second request should throw a too many requests exception"); + } catch (TooManyRequestsException e) { + // based on the fact that we have to + assertThat(e.getSecondsToWait()) + .isBetween(0L, 1L); + assertThat(e.getMessage()) + .isEqualTo("Too many requests"); + } + } + + @Test + public void rateLimitTest_RestAPI_RateLimited() throws Exception { + // warm up request to initialize everything in the system before checking rate limits + ResourceApiAssert.assertResourceSearch(Map.of("limit", 0)); + + // assume user consumed all available tokens from the bucket + rateLimiter.consume(RestExtensions.USER); + rateLimiter.consume(RestExtensions.USER); + + System.err.println("Sending resource request..." + LocalDateTime.now()); + ResourceApiAssert.assertResourceSearch(Map.of("limit", 0)) + .statusCode(429) + .header("X-Rate-Limit-Retry-After-Seconds", anyOf(equalTo("0"), equalTo("1"))); + } + +} diff --git a/core/com.b2international.snowowl.core.rest/src/com/b2international/snowowl/core/rest/util/PromiseMethodReturnValueHandler.java b/core/com.b2international.snowowl.core.rest/src/com/b2international/snowowl/core/rest/util/PromiseMethodReturnValueHandler.java index 0b3e70a4e73..5c6d7524cd4 100644 --- a/core/com.b2international.snowowl.core.rest/src/com/b2international/snowowl/core/rest/util/PromiseMethodReturnValueHandler.java +++ b/core/com.b2international.snowowl.core.rest/src/com/b2international/snowowl/core/rest/util/PromiseMethodReturnValueHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 B2i Healthcare Pte Ltd, http://b2i.sg + * Copyright 2019-2023 B2i Healthcare Pte Ltd, http://b2i.sg * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,7 +18,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; +import org.springframework.http.ResponseEntity.BodyBuilder; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.context.request.async.DeferredResult; import org.springframework.web.context.request.async.WebAsyncUtils; @@ -27,6 +29,7 @@ import org.springframework.web.method.support.ModelAndViewContainer; import com.b2international.snowowl.core.events.util.Promise; +import com.b2international.snowowl.core.events.util.Response; /** * @since 7.2 @@ -51,20 +54,7 @@ public void handleReturnValue(Object returnValue, MethodParameter returnType, Mo final Promise promise = (Promise) returnValue; final DeferredResult> result = new DeferredResult<>(); promise - .then(body -> { - if (result.isSetOrExpired()) { - LOG.warn("Deferred result is already set or expired, could not deliver result {}.", body); - } else { - final ResponseEntity response; - if (body instanceof ResponseEntity) { - response = (ResponseEntity) body; - } else { - response = ResponseEntity.ok().body(body); - } - result.setResult(response); - } - return null; - }) + .thenRespond(promiseResponse -> setDeferredResult(result, promiseResponse)) .fail(err -> { if (result.isSetOrExpired()) { LOG.warn("Deferred result is already set or expired, could not deliver Throwable.", err); @@ -80,6 +70,36 @@ public void handleReturnValue(Object returnValue, MethodParameter returnType, Mo } } + private Response setDeferredResult(DeferredResult> result, Response promiseResponse) { + if (result.isSetOrExpired()) { + LOG.warn("Deferred result is already set or expired, could not deliver result {}.", promiseResponse); + } else { + final Object body = promiseResponse.getBody(); + final ResponseEntity response; + if (body instanceof ResponseEntity b) { + // return a custom ResponseEntity, copy it and apply headers returned from the system + HttpHeaders headers = b.getHeaders(); + // append headers returned from system + promiseResponse.getHeaders().forEach((headerName, headerValue) -> { + headers.set(headerName, headerValue); + }); + response = new ResponseEntity<>(b.getBody(), headers, b.getStatusCode()); + + } else { + // returning a standard object as reponse, use HTTP 200 OK + BodyBuilder responseBuilder = ResponseEntity.ok(); + // append headers returned from system + promiseResponse.getHeaders().forEach((headerName, headerValue) -> { + responseBuilder.header(headerName, headerValue); + }); + response = responseBuilder + .body(body); + } + result.setResult(response); + } + return null; + } + @Override public boolean supportsReturnType(MethodParameter returnType) { Class type = returnType.getParameterType(); diff --git a/core/com.b2international.snowowl.core.tests/src/com/b2international/snowowl/core/events/util/PromiseTest.java b/core/com.b2international.snowowl.core.tests/src/com/b2international/snowowl/core/events/util/PromiseTest.java index 60b82caf507..74df48e0bb2 100644 --- a/core/com.b2international.snowowl.core.tests/src/com/b2international/snowowl/core/events/util/PromiseTest.java +++ b/core/com.b2international.snowowl.core.tests/src/com/b2international/snowowl/core/events/util/PromiseTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2022 B2i Healthcare Pte Ltd, http://b2i.sg + * Copyright 2011-2023 B2i Healthcare Pte Ltd, http://b2i.sg * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,14 +15,21 @@ */ package com.b2international.snowowl.core.events.util; -import static org.junit.Assert.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.*; +import java.io.IOException; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import org.elasticsearch.core.Map; import org.junit.Test; import com.b2international.commons.collections.Procedure; +import com.b2international.commons.exceptions.BadRequestException; +import com.b2international.commons.exceptions.RequestTimeoutException; +import com.b2international.snowowl.core.api.SnowowlRuntimeException; /** * @since 4.2 @@ -33,9 +40,30 @@ public class PromiseTest { Exception rejection = new Exception(); @Test - public void resolveBeforeThenHandlerAdded() throws Exception { + public void unresolvedPromiseState() throws Exception { final Promise p = new Promise<>(); - p.resolve(resolution); + assertFalse(p.isDone()); + assertFalse(p.isCancelled()); + } + + @Test + public void resolvedPromiseState() throws Exception { + final Promise p = Promise.immediate(resolution); + assertTrue(p.isDone()); + assertFalse(p.isCancelled()); + } + + @Test + public void failedPromiseState() throws Exception { + final Promise p = Promise.fail(rejection); + assertTrue(p.isDone()); + assertFalse(p.isCancelled()); + } + + @Test + public void resolveImmediately() throws Exception { + final Promise p = Promise.immediate(resolution); + final CountDownLatch latch = new CountDownLatch(1); p.then(new Procedure() { @Override @@ -48,9 +76,8 @@ protected void doApply(Object input) { } @Test - public void rejectBeforeFailHandlerAdded() throws Exception { - final Promise p = new Promise<>(); - p.reject(rejection); + public void rejectImmediately() throws Exception { + final Promise p = Promise.fail(rejection); final CountDownLatch latch = new CountDownLatch(1); p.fail(input -> { assertEquals(rejection, input); @@ -71,8 +98,13 @@ protected void doApply(Object input) { latch.countDown(); } }); + + assertFalse(p.isDone()); + assertFalse(p.isCancelled()); p.resolve(resolution); latch.await(100, TimeUnit.MILLISECONDS); + assertTrue(p.isDone()); + assertFalse(p.isCancelled()); } @Test @@ -84,8 +116,13 @@ public void rejectAfterFailHandlerAdded() throws Exception { latch.countDown(); return null; }); + + assertFalse(p.isDone()); + assertFalse(p.isCancelled()); p.reject(rejection); latch.await(100, TimeUnit.MILLISECONDS); + assertTrue(p.isDone()); + assertFalse(p.isCancelled()); } @Test @@ -97,4 +134,99 @@ public void resolveWith() throws Exception { assertEquals(Long.valueOf(4L), finalValue); } + @Test + public void failWith() throws Exception { + final Long finalValue = Promise.fail(rejection) + .failWith(error -> Promise.immediate(1L)) + .getSync(); + assertEquals(Long.valueOf(1L), finalValue); + } + + @Test + public void thenRespond() throws Exception { + Response r = Promise.immediateResponse(1L, Map.of("test", "value")) + .thenRespond(response -> Response.of(2L, Map.of("success", response.getHeaders().get("test")))) + .getSyncResponse(); + + assertEquals(Long.valueOf(2L), r.getBody()); + assertEquals(Map.of("success", "value"), r.getHeaders()); + } + + @Test + public void thenRespondWith() throws Exception { + Response r = Promise.immediateResponse(1L, Map.of("test", "value")) + .thenRespondWith(response -> Promise.immediateResponse(2L, Map.of("success", response.getHeaders().get("test")))) + .getSyncResponse(); + + assertEquals(Long.valueOf(2L), r.getBody()); + assertEquals(Map.of("success", "value"), r.getHeaders()); + } + + @Test + public void all() throws Exception { + List waitForAll = Promise.all(Promise.immediate(1L), Promise.immediate(2L)).getSync(); + assertThat(waitForAll).containsExactlyInAnyOrder(1L, 2L); + } + + @Test(expected = RequestTimeoutException.class) + public void timeoutHandling() throws Exception { + Promise p = new Promise<>(); + p.getSync(10, TimeUnit.MILLISECONDS); + } + + @Test(expected = BadRequestException.class) + public void handleApiException() throws Exception { + Promise p = new Promise<>(); + p.reject(new BadRequestException("Invalid request params")); + p.getSync(); + } + + @Test(expected = BadRequestException.class) + public void handleApiExceptionWithTimeout() throws Exception { + Promise p = new Promise<>(); + p.reject(new BadRequestException("Invalid request params")); + p.getSync(1, TimeUnit.SECONDS); + } + + @Test(expected = IllegalArgumentException.class) + public void handleRuntimeException() throws Exception { + Promise p = new Promise<>(); + p.reject(new IllegalArgumentException("Invalid request params")); + p.getSync(); + } + + @Test(expected = IllegalArgumentException.class) + public void handleRuntimeExceptionWithTimeout() throws Exception { + Promise p = new Promise<>(); + p.reject(new IllegalArgumentException("Invalid request params")); + p.getSync(1, TimeUnit.SECONDS); + } + + @Test(expected = SnowowlRuntimeException.class) + public void handleAnyOtherException() throws Exception { + Promise p = new Promise<>(); + p.reject(new IOException("Invalid request params")); + p.getSync(); + } + + @Test(expected = SnowowlRuntimeException.class) + public void handleAnyOtherExceptionWithTimeout() throws Exception { + Promise p = new Promise<>(); + p.reject(new IOException("Invalid request params")); + p.getSync(1, TimeUnit.SECONDS); + } + + @Test + public void resolveWithPromiseHeaders() throws Exception { + Promise promiseWithoutHeader = Promise.immediate(1L) + .thenWith(num -> Promise.immediate(num + 2L)); + Promise promiseWithHeader = promiseWithoutHeader.thenRespond(response -> response.withHeader("test", "value")); + + Response responseWithoutHeader = promiseWithoutHeader.getSyncResponse(); + Response responseWithHeader = promiseWithHeader.getSyncResponse(); + + assertThat(responseWithoutHeader).isEqualTo(Response.of(3L, Map.of())); + assertThat(responseWithHeader).isEqualTo(Response.of(3L, Map.of("test", "value"))); + } + } diff --git a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/util/Handler.java b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/util/Handler.java deleted file mode 100644 index 92ea46385f7..00000000000 --- a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/util/Handler.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2011-2015 B2i Healthcare Pte Ltd, http://b2i.sg - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.b2international.snowowl.core.events.util; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * @since 4.1 - */ -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface Handler { -} diff --git a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/util/Promise.java b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/util/Promise.java index 2039588dcd5..4b4cbb9c6b4 100644 --- a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/util/Promise.java +++ b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/util/Promise.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2022 B2i Healthcare Pte Ltd, http://b2i.sg + * Copyright 2011-2023 B2i Healthcare Pte Ltd, http://b2i.sg * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,11 +19,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +import java.util.concurrent.*; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -31,13 +27,7 @@ import com.b2international.commons.exceptions.ApiException; import com.b2international.commons.exceptions.RequestTimeoutException; import com.b2international.snowowl.core.api.SnowowlRuntimeException; -import com.google.common.annotations.Beta; -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; -import com.google.common.util.concurrent.SettableFuture; +import com.google.common.util.concurrent.*; import io.reactivex.Observable; import io.reactivex.Observer; @@ -52,13 +42,30 @@ public final class Promise extends Observable { final SettableFuture> delegate = SettableFuture.create(); /** - * @return + * @return the response body when this Promise becomes resolved. * @since 4.6 */ - @Beta public T getSync() { + return getSyncResponse().getBody(); + } + + /** + * @param timeout + * @param unit + * @return the response body when this Promise becomes resolved or throw an error if the specified timeout expires. + * @since 4.6 + */ + public T getSync(final long timeout, final TimeUnit unit) { + return getSyncResponse(timeout, unit).getBody(); + } + + /** + * @return the response when this Promise becomes resolved. + * @since 8.10 + */ + public Response getSyncResponse() { try { - return delegate.get().getBody(); + return delegate.get(); } catch (final InterruptedException e) { throw new SnowowlRuntimeException(e); } catch (final ExecutionException e) { @@ -76,13 +83,12 @@ public T getSync() { /** * @param timeout * @param unit - * @return - * @since 4.6 + * @return the response when this Promise becomes resolved or throw an error if the specified timeout expires. + * @since 8.10 */ - @Beta - public T getSync(final long timeout, final TimeUnit unit) { + public Response getSyncResponse(final long timeout, final TimeUnit unit) { try { - return delegate.get(timeout, unit).getBody(); + return delegate.get(timeout, unit); } catch (final TimeoutException e) { throw new RequestTimeoutException("Request timeout", e); } catch (final InterruptedException e) { @@ -111,7 +117,7 @@ public final Promise fail(final Function fail) { @Override public void onSuccess(final Response result) { - promise.resolve(result.getBody()); + promise.resolveResponse(result); } @Override @@ -138,7 +144,7 @@ public final Promise failWith(final Function> fail) { @Override public void onSuccess(final Response result) { - promise.resolve(result.getBody()); + promise.resolveResponse(result); } @Override @@ -211,6 +217,46 @@ public void onFailure(final Throwable t) { }, MoreExecutors.directExecutor()); return transformed; } + + public final Promise thenRespond(final Function, Response> then) { + final Promise transformed = new Promise<>(); + Futures.addCallback(delegate, new FutureCallback>() { + @Override + public void onSuccess(final Response result) { + try { + transformed.resolveResponse(then.apply(result)); + } catch (final Throwable t) { + onFailure(t); + } + } + + @Override + public void onFailure(final Throwable t) { + transformed.reject(t); + } + }, MoreExecutors.directExecutor()); + return transformed; + } + + public final Promise thenRespondWith(final Function, Promise> then) { + final Promise transformed = new Promise<>(); + Futures.addCallback(delegate, new FutureCallback>() { + @Override + public void onSuccess(final Response result) { + try { + transformed.resolveWith(then.apply(result)); + } catch (final Throwable t) { + onFailure(t); + } + } + + @Override + public void onFailure(final Throwable t) { + transformed.reject(t); + } + }, MoreExecutors.directExecutor()); + return transformed; + } /** * Resolves the promise by sending the given result object to all then listeners. @@ -230,26 +276,25 @@ public final void resolve(T result) { * @param headers */ public final void resolve(T result, final Map headers) { - delegate.set(new Response<>(result, headers)); + resolveResponse(Response.of(result, headers)); + } + + public final void resolveResponse(Response response) { + delegate.set(response); } final void resolveWith(final Promise t) { - t.then(new Function() { - @Override - public Void apply(final T input) { - resolve(input); + t + .thenRespond(response -> { + resolveResponse(response); return null; - } - }) - .fail(new Function() { - @Override - public Void apply(final Throwable input) { - reject(input); + }) + .fail(error -> { + reject(error); return null; - } - }); + }); } - + /** * Rejects the promise by sending the {@link Throwable} to all failure listeners. * @@ -264,7 +309,6 @@ public final void reject(final Throwable throwable) { * @return * @since 4.6 */ - @Beta public static Promise wrap(final Callable func) { final ListeningExecutorService executor = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(1)); final ListenableFuture submit = executor.submit(func); @@ -277,7 +321,6 @@ public static Promise wrap(final Callable func) { * @return * @since 4.6 */ - @Beta public static Promise> all(final Collection> promises) { return Promise.wrap(Futures.allAsList(promises.stream().map(p -> p.delegate).collect(Collectors.toList()))) .then(responses -> { @@ -290,7 +333,6 @@ public static Promise> all(final Collection> p * @return * @since 4.6 */ - @Beta public static Promise> all(final Promise...promises) { return Promise.wrap(Futures.allAsList(Stream.of(promises).map(p -> p.delegate).collect(Collectors.toList()))) .then(responses -> { @@ -326,13 +368,25 @@ public void onFailure(final Throwable t) { * @return * @since 4.6 */ - @Beta public static final Promise immediate(final T value) { final Promise promise = new Promise<>(); promise.resolve(value); return promise; } + /** + * Provides a promise object with type T that is available immediately. It also allows specifying additional headers to be associated with the {@link Promise}. + * + * @param value + * @return + * @since 8.10 + */ + public static final Promise immediateResponse(final T value, final Map headers) { + final Promise promise = new Promise<>(); + promise.resolve(value, headers); + return promise; + } + /** * Returns a {@link Promise} that will always fail with the given {@link Throwable}. * @param throwable @@ -364,5 +418,5 @@ public boolean isDone() { public boolean isCancelled() { return delegate.isCancelled(); } - + } diff --git a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/util/Response.java b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/util/Response.java index b6f1abce069..e3e28c45970 100644 --- a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/util/Response.java +++ b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/util/Response.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 B2i Healthcare Pte Ltd, http://b2i.sg + * Copyright 2019-2023 B2i Healthcare Pte Ltd, http://b2i.sg * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,7 +15,9 @@ */ package com.b2international.snowowl.core.events.util; +import java.util.HashMap; import java.util.Map; +import java.util.Objects; import com.google.common.collect.ImmutableMap; @@ -27,7 +29,7 @@ public final class Response { private final Map headers; private final T body; - public Response(T body, Map headers) { + private Response(T body, Map headers) { this.body = body; this.headers = ImmutableMap.copyOf(headers); } @@ -40,4 +42,32 @@ public T getBody() { return body; } + public Response withHeader(String key, String value) { + final Map newHeaders = new HashMap<>(this.headers); + newHeaders.put(key, value); + return of(this.body, newHeaders); + } + + public Response withHeaders(Map headers) { + return of(this.body, headers); + } + + public static Response of(T body, Map headers) { + return new Response(body, headers); + } + + @Override + public int hashCode() { + return Objects.hash(body, headers); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + Response other = (Response) obj; + return Objects.equals(body, other.body) && Objects.equals(headers, other.headers); + } + } diff --git a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/rate/ApiPlugin.java b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/rate/ApiPlugin.java index 203f423b0a4..a28434ad82b 100644 --- a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/rate/ApiPlugin.java +++ b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/rate/ApiPlugin.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 B2i Healthcare Pte Ltd, http://b2i.sg + * Copyright 2019-2023 B2i Healthcare Pte Ltd, http://b2i.sg * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import com.b2international.snowowl.core.setup.ConfigurationRegistry; import com.b2international.snowowl.core.setup.Environment; import com.b2international.snowowl.core.setup.Plugin; +import com.google.common.annotations.VisibleForTesting; /** * @since 7.2 @@ -35,9 +36,14 @@ public void addConfigurations(ConfigurationRegistry registry) { @Override public void init(SnowOwlConfiguration configuration, Environment env) throws Exception { ApiConfiguration apiConfig = configuration.getModuleConfig(ApiConfiguration.class); + initRateLimiter(env, apiConfig); + } + + @VisibleForTesting + public void initRateLimiter(Environment env, ApiConfiguration apiConfig) { final RateLimiter limiter; if (apiConfig.getOverdraft() > 0L) { - limiter = new Bucket4jRateLimiter(configuration.getModuleConfig(ApiConfiguration.class)); + limiter = new Bucket4jRateLimiter(apiConfig); } else { limiter = RateLimiter.NOOP; } diff --git a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/rate/RateLimitingRequest.java b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/rate/RateLimitingRequest.java index e63d47e724d..1e364886437 100644 --- a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/rate/RateLimitingRequest.java +++ b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/rate/RateLimitingRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2019-2022 B2i Healthcare Pte Ltd, http://b2i.sg + * Copyright 2019-2023 B2i Healthcare Pte Ltd, http://b2i.sg * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,8 @@ */ public final class RateLimitingRequest extends DelegatingRequest { + private static final long serialVersionUID = 1L; + public RateLimitingRequest(Request next) { super(next); }