diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/route/Route.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/route/Route.java index 82e8f32c44a9..26b523529053 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/route/Route.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/route/Route.java @@ -48,6 +48,7 @@ @EqualsAndHashCode(callSuper = true) @Accessors(chain = true) public class Route extends BaseIdentifiableObject implements MetadataObject { + public static final int DEFAULT_RESPONSE_TIMEOUT_SECONDS = 5; public static final String PATH_WILDCARD_SUFFIX = "/**"; @JsonProperty private String description; @@ -67,6 +68,9 @@ public class Route extends BaseIdentifiableObject implements MetadataObject { /** Optional. Required authorities for invoking the route. */ @JsonProperty private List authorities = new ArrayList<>(); + @JsonProperty(defaultValue = "" + DEFAULT_RESPONSE_TIMEOUT_SECONDS) + private int responseTimeoutSeconds = DEFAULT_RESPONSE_TIMEOUT_SECONDS; + /** * If the route url ends with /** return true. Otherwise return false. * diff --git a/dhis-2/dhis-services/dhis-service-core/pom.xml b/dhis-2/dhis-services/dhis-service-core/pom.xml index dae45dad2ee3..15587d61b598 100644 --- a/dhis-2/dhis-services/dhis-service-core/pom.xml +++ b/dhis-2/dhis-services/dhis-service-core/pom.xml @@ -69,10 +69,6 @@ dhis-support-sql - - org.apache.httpcomponents.core5 - httpcore5 - org.hisp.dhis.parser dhis-antlr-expression-parser @@ -97,6 +93,31 @@ org.springframework.security spring-security-oauth2-core + + org.springframework + spring-webflux + ${spring.version} + + + io.projectreactor + reactor-core + 3.6.9 + + + io.projectreactor.netty + reactor-netty-http + 1.2.2 + + + org.reactivestreams + reactive-streams + 1.0.4 + + + io.netty + netty-handler + 4.1.116.Final + com.nimbusds nimbus-jose-jwt @@ -197,10 +218,6 @@ com.google.code.gson gson - - org.apache.httpcomponents.client5 - httpclient5 - @@ -290,6 +307,7 @@ org.springframework spring-webmvc + org.springframework.security spring-security-crypto diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/route/RouteService.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/route/RouteService.java index caa3603e2c1d..3d2ec8df8b98 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/route/RouteService.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/route/RouteService.java @@ -29,9 +29,10 @@ import static org.hisp.dhis.config.HibernateEncryptionConfig.AES_128_STRING_ENCRYPTOR; +import io.netty.handler.timeout.ReadTimeoutException; import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; -import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -45,31 +46,31 @@ import java.util.function.Function; import javax.annotation.Nonnull; import javax.annotation.PostConstruct; -import lombok.Getter; import lombok.RequiredArgsConstructor; -import lombok.Setter; import lombok.extern.slf4j.Slf4j; -import org.apache.hc.client5.http.config.ConnectionConfig; -import org.apache.hc.client5.http.config.RequestConfig; -import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; -import org.apache.hc.core5.http.io.SocketConfig; -import org.apache.hc.core5.util.Timeout; import org.hibernate.Hibernate; import org.hisp.dhis.feedback.BadRequestException; import org.hisp.dhis.user.UserDetails; import org.jasypt.encryption.pbe.PBEStringCleanablePasswordEncryptor; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.http.HttpEntity; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.client.reactive.ClientHttpConnector; import org.springframework.stereotype.Service; -import org.springframework.util.StreamUtils; import org.springframework.util.StringUtils; -import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import org.springframework.web.util.UriComponentsBuilder; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClientRequest; /** * @author Morten Olav Hansen @@ -85,7 +86,11 @@ public class RouteService { @Qualifier(AES_128_STRING_ENCRYPTOR) private final PBEStringCleanablePasswordEncryptor encryptor; - @Autowired @Getter @Setter private RestTemplate restTemplate; + private final ClientHttpConnector clientHttpConnector; + + private DataBufferFactory dataBufferFactory; + + private WebClient webClient; private static final Set ALLOWED_REQUEST_HEADERS = Set.of( @@ -120,29 +125,8 @@ public class RouteService { @PostConstruct public void postConstruct() { - // Connect timeout - ConnectionConfig connectionConfig = - ConnectionConfig.custom().setConnectTimeout(Timeout.ofMilliseconds(5_000)).build(); - - // Socket timeout - SocketConfig socketConfig = - SocketConfig.custom().setSoTimeout(Timeout.ofMilliseconds(10_000)).build(); - - // Connection request timeout - RequestConfig requestConfig = - RequestConfig.custom().setConnectionRequestTimeout(Timeout.ofMilliseconds(1_000)).build(); - - PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); - connectionManager.setDefaultSocketConfig(socketConfig); - connectionManager.setDefaultConnectionConfig(connectionConfig); - - org.apache.hc.client5.http.classic.HttpClient httpClient = - org.apache.hc.client5.http.impl.classic.HttpClientBuilder.create() - .setConnectionManager(connectionManager) - .setDefaultRequestConfig(requestConfig) - .build(); - - restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory(httpClient)); + webClient = WebClient.builder().clientConnector(clientHttpConnector).build(); + dataBufferFactory = new DefaultDataBufferFactory(); } /** @@ -185,7 +169,7 @@ public Route getRouteWithDecryptedAuth(@Nonnull String id) { * @throws IOException * @throws BadRequestException */ - public ResponseEntity execute( + public ResponseEntity execute( Route route, UserDetails userDetails, Optional subPath, HttpServletRequest request) throws IOException, BadRequestException { @@ -222,14 +206,33 @@ public ResponseEntity execute( uriComponentsBuilder.path(subPath.get()); } - String body = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); - - HttpEntity entity = new HttpEntity<>(body, headers); - HttpMethod httpMethod = Objects.requireNonNullElse(HttpMethod.valueOf(request.getMethod()), HttpMethod.GET); String targetUri = uriComponentsBuilder.toUriString(); + Flux requestBodyFlux = + DataBufferUtils.read( + new InputStreamResource(request.getInputStream()), dataBufferFactory, 8192) + .doOnNext(DataBufferUtils.releaseConsumer()); + WebClient.RequestHeadersSpec requestHeadersSpec = + webClient + .method(httpMethod) + .uri(targetUri) + .httpRequest( + clientHttpRequest -> { + Object nativeRequest = clientHttpRequest.getNativeRequest(); + if (nativeRequest instanceof HttpClientRequest httpClientRequest) { + httpClientRequest.responseTimeout( + Duration.ofSeconds(route.getResponseTimeoutSeconds())); + } + }) + .body(requestBodyFlux, DataBuffer.class); + + for (Map.Entry> header : headers.entrySet()) { + requestHeadersSpec = + requestHeadersSpec.header(header.getKey(), header.getValue().toArray(new String[0])); + } + log.info( "Sending '{}' '{}' with route '{}' ('{}')", httpMethod, @@ -237,22 +240,45 @@ public ResponseEntity execute( route.getName(), route.getUid()); - ResponseEntity response = - restTemplate.exchange(targetUri, httpMethod, entity, String.class); - - HttpHeaders responseHeaders = filterResponseHeaders(response.getHeaders()); + WebClient.ResponseSpec responseSpec = + requestHeadersSpec + .retrieve() + .onStatus(httpStatusCode -> true, clientResponse -> Mono.empty()); - String responseBody = response.getBody(); + ResponseEntity> responseEntityFlux = + responseSpec + .toEntityFlux(DataBuffer.class) + .onErrorReturn( + throwable -> throwable.getCause() instanceof ReadTimeoutException, + new ResponseEntity<>(HttpStatus.GATEWAY_TIMEOUT)) + .block(); log.info( "Request '{}' '{}' responded with status '{}' for route '{}' ('{}')", httpMethod, targetUri, - response.getStatusCode(), + responseEntityFlux.getStatusCode(), route.getName(), route.getUid()); - return new ResponseEntity<>(responseBody, responseHeaders, response.getStatusCode()); + StreamingResponseBody streamingResponseBody = + out -> { + if (responseEntityFlux.getBody() != null) { + try { + Flux dataBufferFlux = + DataBufferUtils.write(responseEntityFlux.getBody(), out) + .doOnNext(DataBufferUtils.releaseConsumer()); + dataBufferFlux.blockLast(Duration.ofMinutes(5)); + } catch (Exception e) { + out.close(); + throw e; + } + } + }; + return new ResponseEntity<>( + streamingResponseBody, + filterResponseHeaders(responseEntityFlux.getHeaders()), + responseEntityFlux.getStatusCode()); } /** diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/route/hibernate/Route.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/route/hibernate/Route.hbm.xml index 5a70e4152c75..23712f5ede3a 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/route/hibernate/Route.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/route/hibernate/Route.hbm.xml @@ -39,5 +39,7 @@ + + diff --git a/dhis-2/dhis-support/dhis-support-db-migration/src/main/resources/org/hisp/dhis/db/migration/2.42/V2_42_36__Add_responsetimeout_route.sql b/dhis-2/dhis-support/dhis-support-db-migration/src/main/resources/org/hisp/dhis/db/migration/2.42/V2_42_36__Add_responsetimeout_route.sql new file mode 100644 index 000000000000..83230dd50cfa --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-db-migration/src/main/resources/org/hisp/dhis/db/migration/2.42/V2_42_36__Add_responsetimeout_route.sql @@ -0,0 +1 @@ +ALTER TABLE route ADD COLUMN IF NOT EXISTS responsetimeoutseconds INTEGER NOT NULL DEFAULT 5 CHECK (responsetimeoutseconds > 0 AND responsetimeoutseconds <= 60); \ No newline at end of file diff --git a/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/support/config/ServiceConfig.java b/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/support/config/ServiceConfig.java index 436fa502e221..c5606883fa5b 100644 --- a/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/support/config/ServiceConfig.java +++ b/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/support/config/ServiceConfig.java @@ -29,6 +29,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; @@ -42,6 +44,11 @@ public RestTemplate restTemplate() { return new RestTemplate(); } + @Bean + public ClientHttpConnector clientHttpConnector() { + return new ReactorClientHttpConnector(); + } + @Bean public UriComponentsBuilder uriComponentsBuilder() { return UriComponentsBuilder.newInstance(); diff --git a/dhis-2/dhis-test-web-api/pom.xml b/dhis-2/dhis-test-web-api/pom.xml index 5f1ea4868571..19798b76e655 100644 --- a/dhis-2/dhis-test-web-api/pom.xml +++ b/dhis-2/dhis-test-web-api/pom.xml @@ -361,6 +361,17 @@ + + org.testcontainers + testcontainers + test + + + org.mock-server + mockserver-client-java + 5.15.0 + test + diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/test/webapi/ControllerIntegrationTestBase.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/test/webapi/ControllerIntegrationTestBase.java index 3a67ac6083f5..6c978cace60e 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/test/webapi/ControllerIntegrationTestBase.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/test/webapi/ControllerIntegrationTestBase.java @@ -248,6 +248,13 @@ public String getHeader(String name) { } } + protected final MvcResult webRequestWithAsyncMvcResult(MockHttpServletRequestBuilder request) { + return exceptionAsFail( + () -> + mvc.perform(MockMvcRequestBuilders.asyncDispatch(webRequestWithMvcResult(request))) + .andReturn()); + } + protected final MvcResult webRequestWithMvcResult(MockHttpServletRequestBuilder request) { return exceptionAsFail(() -> mvc.perform(request.session(session)).andReturn()); } diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/RouteControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/RouteControllerTest.java index 2cf4072fb170..96f036dd11e0 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/RouteControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/RouteControllerTest.java @@ -31,108 +31,264 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockserver.model.HttpRequest.request; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import java.net.MalformedURLException; -import java.net.URL; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; import org.hisp.dhis.common.auth.ApiHeadersAuthScheme; import org.hisp.dhis.common.auth.ApiQueryParamsAuthScheme; +import org.hisp.dhis.http.HttpMethod; import org.hisp.dhis.http.HttpStatus; import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.jsontree.JsonString; +import org.hisp.dhis.jsontree.JsonValue; import org.hisp.dhis.route.RouteService; import org.hisp.dhis.test.webapi.PostgresControllerIntegrationTestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; +import org.mockserver.client.MockServerClient; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatusCode; -import org.springframework.http.ResponseEntity; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.client.MockMvcHttpConnector; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestTemplate; +import org.springframework.web.context.WebApplicationContext; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; @Transactional +@ContextConfiguration(classes = {RouteControllerTest.ClientHttpConnectorTestConfig.class}) class RouteControllerTest extends PostgresControllerIntegrationTestBase { @Autowired private RouteService service; @Autowired private ObjectMapper jsonMapper; + @Autowired private ClientHttpConnector clientHttpConnector; + + @Configuration + public static class ClientHttpConnectorTestConfig { + @Autowired private ObjectMapper jsonMapper; + + @Autowired private WebApplicationContext webApplicationContext; + + @Bean + public ClientHttpConnector clientHttpConnector() { + MockMvc mockMvc = + MockMvcBuilders.webAppContextSetup(webApplicationContext) + .addFilter( + (request, response, chain) -> { + Map headers = new HashMap<>(); + for (String headerName : + Collections.list(((MockHttpServletRequest) request).getHeaderNames())) { + headers.put( + headerName, ((MockHttpServletRequest) request).getHeader(headerName)); + } + + String queryString = ((MockHttpServletRequest) request).getQueryString(); + response.setContentType("application/json"); + + response + .getWriter() + .write( + jsonMapper.writeValueAsString( + Map.of( + "name", + "John Doe", + "headers", + headers, + "queryString", + queryString != null ? queryString : ""))); + }) + .build(); + return new MockMvcHttpConnector(mockMvc); + } + } + + @Transactional + @Nested + public class IntegrationTest extends PostgresControllerIntegrationTestBase { + + private static GenericContainer routeTargetMockServerContainer; + private MockServerClient routeTargetMockServerClient; + + @BeforeAll + public static void beforeAll() { + routeTargetMockServerContainer = + new GenericContainer<>("mockserver/mockserver") + .waitingFor(new HttpWaitStrategy().forStatusCode(404)) + .withExposedPorts(1080); + routeTargetMockServerContainer.start(); + } + + @BeforeEach + public void beforeEach() { + routeTargetMockServerClient = + new MockServerClient("localhost", routeTargetMockServerContainer.getFirstMappedPort()); + } + + @AfterEach + public void afterEach() { + routeTargetMockServerClient.reset(); + } + + @Test + void testRunRouteWhenResponseDurationExceedsRouteResponseTimeout() + throws JsonProcessingException { + routeTargetMockServerClient + .when(request().withPath("/")) + .respond( + org.mockserver.model.HttpResponse.response("{}").withDelay(TimeUnit.SECONDS, 10)); + + Map route = new HashMap<>(); + route.put("name", "route-under-test"); + route.put("url", "http://localhost:" + routeTargetMockServerContainer.getFirstMappedPort()); + route.put("responseTimeoutSeconds", 5); + + HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route)); + MvcResult mvcResult = + webRequestWithAsyncMvcResult( + buildMockRequest( + HttpMethod.GET, + "/routes/" + + postHttpResponse.content().get("response.uid").as(JsonString.class).string() + + "/run", + new ArrayList<>(), + "application/json", + null)); + + assertEquals(504, mvcResult.getResponse().getStatus()); + } + + @Test + void testRunRouteWhenResponseDurationDoesNotExceedRouteResponseTimeout() + throws JsonProcessingException { + routeTargetMockServerClient + .when(request().withPath("/")) + .respond(org.mockserver.model.HttpResponse.response("{}")); + + Map route = new HashMap<>(); + route.put("name", "route-under-test"); + route.put("url", "http://localhost:" + routeTargetMockServerContainer.getFirstMappedPort()); + route.put("responseTimeoutSeconds", 5); + + HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route)); + MvcResult mvcResult = + webRequestWithAsyncMvcResult( + buildMockRequest( + HttpMethod.GET, + "/routes/" + + postHttpResponse.content().get("response.uid").as(JsonString.class).string() + + "/run", + new ArrayList<>(), + "application/json", + null)); + + assertEquals(200, mvcResult.getResponse().getStatus()); + } + + @Test + void testRunRouteWhenResponseIsHttpError() + throws JsonProcessingException, UnsupportedEncodingException { + routeTargetMockServerClient + .when(request().withPath("/")) + .respond( + org.mockserver.model.HttpResponse.response( + jsonMapper.writeValueAsString(Map.of("message", "not found"))) + .withStatusCode(404)); + + Map route = new HashMap<>(); + route.put("name", "route-under-test"); + route.put("url", "http://localhost:" + routeTargetMockServerContainer.getFirstMappedPort()); + + HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route)); + MvcResult mvcResult = + webRequestWithAsyncMvcResult( + buildMockRequest( + HttpMethod.GET, + "/routes/" + + postHttpResponse.content().get("response.uid").as(JsonString.class).string() + + "/run", + new ArrayList<>(), + "application/json", + null)); + + assertEquals(404, mvcResult.getResponse().getStatus()); + JsonObject responseBody = + JsonValue.of(mvcResult.getResponse().getContentAsString()).asObject(); + assertEquals("not found", responseBody.get("message").as(JsonString.class).string()); + } + } + @Test void testRunRouteGivenApiQueryParamsAuthScheme() - throws JsonProcessingException, MalformedURLException { - ArgumentCaptor urlArgumentCaptor = ArgumentCaptor.forClass(String.class); - - RestTemplate mockRestTemplate = mock(RestTemplate.class); - when(mockRestTemplate.exchange( - urlArgumentCaptor.capture(), - any(HttpMethod.class), - any(HttpEntity.class), - any(Class.class))) - .thenReturn( - new ResponseEntity<>( - jsonMapper.writeValueAsString(Map.of("name", "John Doe")), - HttpStatusCode.valueOf(200))); - service.setRestTemplate(mockRestTemplate); - + throws JsonProcessingException, UnsupportedEncodingException { Map route = new HashMap<>(); route.put("name", "route-under-test"); route.put("auth", Map.of("type", "api-query-params", "queryParams", Map.of("token", "foo"))); route.put("url", "http://stub"); HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route)); - HttpResponse runHttpResponse = - GET( - "/routes/{id}/run", - postHttpResponse.content().get("response.uid").as(JsonString.class).string()); - assertStatus(HttpStatus.OK, runHttpResponse); - assertEquals("John Doe", runHttpResponse.content().get("name").as(JsonString.class).string()); + MvcResult mvcResult = + webRequestWithAsyncMvcResult( + buildMockRequest( + HttpMethod.GET, + "/routes/" + + postHttpResponse.content().get("response.uid").as(JsonString.class).string() + + "/run", + new ArrayList<>(), + "application/json", + null)); + + assertEquals(200, mvcResult.getResponse().getStatus()); + JsonObject responseBody = JsonValue.of(mvcResult.getResponse().getContentAsString()).asObject(); - assertEquals("token=foo", new URL(urlArgumentCaptor.getValue()).getQuery()); + assertEquals("John Doe", responseBody.get("name").as(JsonString.class).string()); + assertEquals("token=foo", responseBody.get("queryString").as(JsonString.class).string()); } @Test - void testRunRouteGivenApiHeadersAuthScheme() throws JsonProcessingException { - ArgumentCaptor> httpEntityArgumentCaptor = - ArgumentCaptor.forClass(HttpEntity.class); - - RestTemplate mockRestTemplate = mock(RestTemplate.class); - when(mockRestTemplate.exchange( - anyString(), - any(HttpMethod.class), - httpEntityArgumentCaptor.capture(), - any(Class.class))) - .thenReturn( - new ResponseEntity<>( - jsonMapper.writeValueAsString(Map.of("name", "John Doe")), - HttpStatusCode.valueOf(200))); - service.setRestTemplate(mockRestTemplate); - + void testRunRouteGivenApiHeadersAuthScheme() + throws JsonProcessingException, UnsupportedEncodingException { Map route = new HashMap<>(); route.put("name", "route-under-test"); route.put("auth", Map.of("type", "api-headers", "headers", Map.of("X-API-KEY", "foo"))); route.put("url", "http://stub"); HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route)); - HttpResponse runHttpResponse = - GET( - "/routes/{id}/run", - postHttpResponse.content().get("response.uid").as(JsonString.class).string()); - assertStatus(HttpStatus.OK, runHttpResponse); - assertEquals("John Doe", runHttpResponse.content().get("name").as(JsonString.class).string()); + MvcResult mvcResult = + webRequestWithAsyncMvcResult( + buildMockRequest( + HttpMethod.GET, + "/routes/" + + postHttpResponse.content().get("response.uid").as(JsonString.class).string() + + "/run", + new ArrayList<>(), + "application/json", + null)); - HttpEntity capturedHttpEntity = httpEntityArgumentCaptor.getValue(); - HttpHeaders headers = capturedHttpEntity.getHeaders(); - assertEquals("foo", headers.get("X-API-KEY").get(0)); + assertEquals(200, mvcResult.getResponse().getStatus()); + JsonObject responseBody = JsonValue.of(mvcResult.getResponse().getContentAsString()).asObject(); + assertEquals("John Doe", responseBody.asObject().get("name").as(JsonString.class).string()); + assertEquals( + "foo", + responseBody.get("headers").asObject().get("X-API-KEY").as(JsonString.class).string()); } @Test @@ -199,4 +355,62 @@ void testGetRouteGivenApiQueryParamsAuthScheme() throws JsonProcessingException getHttpResponse.content().get("auth.type").as(JsonString.class).string()); assertFalse(getHttpResponse.content().get("auth").as(JsonObject.class).has("queryParams")); } + + @Test + void testAddRouteGivenResponseTimeoutGreaterThanMax() throws JsonProcessingException { + Map route = new HashMap<>(); + route.put("name", "route-under-test"); + route.put("url", "http://stub"); + route.put("responseTimeoutSeconds", ThreadLocalRandom.current().nextInt(61, Integer.MAX_VALUE)); + + HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route)); + assertStatus(HttpStatus.CONFLICT, postHttpResponse); + } + + @Test + void testAddRouteGivenResponseTimeoutLessThanMin() throws JsonProcessingException { + Map route = new HashMap<>(); + route.put("name", "route-under-test"); + route.put("url", "http://stub"); + route.put("responseTimeoutSeconds", ThreadLocalRandom.current().nextInt(Integer.MIN_VALUE, 1)); + + HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route)); + assertStatus(HttpStatus.CONFLICT, postHttpResponse); + } + + @Test + void testUpdateRouteGivenResponseTimeoutGreaterThanMax() throws JsonProcessingException { + Map route = new HashMap<>(); + route.put("name", "route-under-test"); + route.put("url", "http://stub"); + + HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route)); + + route.put("responseTimeoutSeconds", ThreadLocalRandom.current().nextInt(61, Integer.MAX_VALUE)); + HttpResponse updateHttpResponse = + PUT( + "/routes/" + + postHttpResponse.content().get("response.uid").as(JsonString.class).string(), + jsonMapper.writeValueAsString(route)); + + assertStatus(HttpStatus.CONFLICT, updateHttpResponse); + } + + @Test + void testUpdateRouteGivenResponseTimeoutLessThanMin() throws JsonProcessingException { + Map route = new HashMap<>(); + route.put("name", "route-under-test"); + route.put("url", "http://stub"); + + HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route)); + + route.put("responseTimeoutSeconds", ThreadLocalRandom.current().nextInt(Integer.MIN_VALUE, 1)); + HttpResponse updateHttpResponse = + PUT( + "/routes/" + + postHttpResponse.content().get("response.uid").as(JsonString.class).string(), + jsonMapper.writeValueAsString(route)); + + assertStatus(HttpStatus.CONFLICT, updateHttpResponse); + } } diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/RouteController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/RouteController.java index fdda5c0d2d13..0d9b230dbd2f 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/RouteController.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/RouteController.java @@ -54,6 +54,7 @@ import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; /** * @author Morten Olav Hansen @@ -75,7 +76,7 @@ public class RouteController extends AbstractCrudController run( + public ResponseEntity run( @PathVariable("id") String id, @CurrentUser UserDetails currentUser, HttpServletRequest request) @@ -92,7 +93,7 @@ public ResponseEntity run( RequestMethod.DELETE, RequestMethod.PATCH }) - public ResponseEntity runWithSubpath( + public ResponseEntity runWithSubpath( @PathVariable("id") String id, @CurrentUser UserDetails currentUser, HttpServletRequest request) @@ -129,6 +130,23 @@ private Optional getSubPath(String path, String id) { return Optional.empty(); } + @Override + protected void preCreateEntity(Route route) throws ConflictException { + validateRoute(route); + } + + @Override + protected void preUpdateEntity(Route route, Route newRoute) throws ConflictException { + validateRoute(newRoute); + } + + protected void validateRoute(Route route) throws ConflictException { + if (route.getResponseTimeoutSeconds() < 1 || route.getResponseTimeoutSeconds() > 60) { + throw new ConflictException( + "Route response timeout must be greater than 0 seconds and less than or equal to 60 seconds"); + } + } + /** * Disable the collection API for /api/routes endpoint. This conflicts with sub-path based routes * and is not supported by the Route API (no identifiable object collections). diff --git a/dhis-2/pom.xml b/dhis-2/pom.xml index b73852ba96a9..a6cd0ce234b9 100644 --- a/dhis-2/pom.xml +++ b/dhis-2/pom.xml @@ -103,7 +103,7 @@ 3.5.3 - 6.1.12 + 6.1.17 3.4.1 2.7.18 2.7.4 @@ -2152,6 +2152,7 @@ jasperreports.version=${jasperreports.version} org.springframework.session:spring-session-core jakarta.jms:jakarta.jms-api org.apache.activemq:artemis-server + org.mock-server:mockserver-core:jar