From a94029b0ee9010e0d0fbe5c87c57c4e7bda4faa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Jare=C5=A1?= <58428711+pj892031@users.noreply.github.com> Date: Tue, 12 Nov 2024 15:39:38 +0100 Subject: [PATCH] fix: Semantic of onboarded Gateways in the multitenancy deployment (#3884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * draft Signed-off-by: Pavel Jareš * InstanceRetrievalServiceTest Signed-off-by: Pavel Jareš * CachedProductFamilyServiceTest Signed-off-by: Pavel Jareš * verify routes update Signed-off-by: ac892247 * update routes Signed-off-by: ac892247 * fix proper instance of Gateway in the services - ie. to fix login to central domain (InstanceLookupExecutor + InstanceLookupExecutorTest) Signed-off-by: Pavel Jareš * verify that catalog can work with multiple gateways registered Signed-off-by: ac892247 * new amount of gateways Signed-off-by: ac892247 * wait for all services to start Signed-off-by: ac892247 * specify zaas 2 hostname Signed-off-by: ac892247 --------- Signed-off-by: Pavel Jareš Signed-off-by: ac892247 Co-authored-by: ac892247 --- .github/workflows/integration-tests.yml | 44 ++- .../instance/InstanceRetrievalService.java | 85 ++-- .../cached/CachedProductFamilyService.java | 31 +- .../InstanceRetrievalServiceTest.java | 367 +++++++++++------- .../CachedProductFamilyServiceTest.java | 61 ++- .../cypress/e2e/dashboard/dashboard.cy.js | 4 +- .../cypress/e2e/detail-page/detail-page.cy.js | 2 +- .../multiple-gateway-services.cy.js | 112 ++++++ .../detail-page/service-version-compare.cy.js | 2 +- .../lookup/InstanceLookupExecutor.java | 12 +- .../lookup/InstanceLookupExecutorTest.java | 355 ++++++++++------- .../constants/EurekaMetadataDefinition.java | 59 +++ .../gateway/config/ConnectionsConfig.java | 36 +- .../controllers/GatewayExceptionHandler.java | 2 +- .../src/main/resources/application.yml | 1 + .../config/AdditionalRegistrationTest.java | 26 +- .../environment-configuration-ha.yml | 1 - 17 files changed, 826 insertions(+), 374 deletions(-) create mode 100644 api-catalog-ui/frontend/cypress/e2e/detail-page/multiple-gateway-services.cy.js diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 264746b6f3..fd880eda35 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -307,8 +307,8 @@ jobs: APIML_SERVICE_PORT: 10037 APIML_SERVICE_DISCOVERYSERVICEURLS: https://discovery-service-2:10031/eureka/ ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_DISCOVERYSERVICEURLS: https://discovery-service:10011/eureka - ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_GATEWAYURL: / - ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_SERVICEURL: / + ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_0_GATEWAYURL: / + ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_0_SERVICEURL: / zaas-service-2: image: ghcr.io/balhar-jakub/zaas-service:${{ github.run_id }}-${{ github.run_number }} env: @@ -325,8 +325,8 @@ jobs: APIML_SECURITY_X509_REGISTRY_ALLOWEDUSERS: USER,UNKNOWNUSER APIML_SERVICE_DISCOVERYSERVICEURLS: https://discovery-service-2:10031/eureka/ ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_DISCOVERYSERVICEURLS: https://discovery-service:10011/eureka - ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_GATEWAYURL: / - ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_SERVICEURL: / + ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_0_GATEWAYURL: / + ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_0_SERVICEURL: / steps: - uses: actions/checkout@v4 @@ -1098,7 +1098,7 @@ jobs: - uses: ./.github/actions/teardown - CITestsDicoverableClientChaoticHA: + CITestsDiscoverableClientChaoticHA: needs: PublishJibContainers container: ubuntu:latest runs-on: ubuntu-latest @@ -1169,7 +1169,7 @@ jobs: uses: actions/upload-artifact@v4 if: always() with: - name: CITestsDicoverableClientChaoticHA-${{ env.JOB_ID }} + name: CITestsDiscoverableClientChaoticHA-${{ env.JOB_ID }} path: | integration-tests/build/reports/** @@ -1366,11 +1366,40 @@ jobs: SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OKTA_USERNAMEATTRIBUTE: sub SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OKTA_JWKSETURI: ${{ secrets.OKTA_JWKSET_URI }} APIML_SECURITY_OIDC_COOKIE_SAMESITE: None + APIML_HEALTH_PROTECTED: false zaas-service: image: ghcr.io/balhar-jakub/zaas-service:${{ github.run_id }}-${{ github.run_number }} + env: + APIML_HEALTH_PROTECTED: false mock-services: image: ghcr.io/balhar-jakub/mock-services:${{ github.run_id }}-${{ github.run_number }} + discovery-service-2: + image: ghcr.io/balhar-jakub/discovery-service:${{ github.run_id }}-${{ github.run_number }} + volumes: + - /api-defs:/api-defs + env: + APIML_SERVICE_HOSTNAME: discovery-service-2 + gateway-service-2: + image: ghcr.io/balhar-jakub/gateway-service:${{ github.run_id }}-${{ github.run_number }} + env: + APIML_SERVICE_APIMLID: apiml2 + APIML_SERVICE_HOSTNAME: gateway-service-2 + APIML_SERVICE_DISCOVERYSERVICEURLS: https://discovery-service-2:10011/eureka/ + APIML_HEALTH_PROTECTED: false + ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_DISCOVERYSERVICEURLS: https://discovery-service:10011/eureka + ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_0_GATEWAYURL: / + ZWE_CONFIGS_APIML_SERVICE_ADDITIONALREGISTRATION_0_ROUTES_0_SERVICEURL: / + zaas-service-2: + image: ghcr.io/balhar-jakub/zaas-service:${{ github.run_id }}-${{ github.run_number }} + env: + APIML_SECURITY_X509_ENABLED: true + APIML_SECURITY_X509_ACCEPTFORWARDEDCERT: true + APIML_SECURITY_X509_CERTIFICATESURL: https://gateway-service-2:10010/gateway/certificates + APIML_SERVICE_DISCOVERYSERVICEURLS: https://discovery-service-2:10011/eureka/ + APIML_HEALTH_PROTECTED: false + APIML_SERVICE_HOSTNAME: zaas-service-2 + steps: - uses: actions/checkout@v4 with: @@ -1388,6 +1417,9 @@ jobs: ~/.cache/Cypress api-catalog-ui/frontend/node_modules key: my-cache-${{ runner.os }}-${{ hashFiles('api-catalog-ui/frontend/*.json') }} + - name: Run startup check + run: > + ./gradlew runStartUpCheck --info --scan -Denvironment.config=-ha -Ddiscoverableclient.instances=1 - name: Cypress run API Catalog run: | cd api-catalog-ui/frontend diff --git a/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/instance/InstanceRetrievalService.java b/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/instance/InstanceRetrievalService.java index f99c9964c6..9525b6e14f 100644 --- a/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/instance/InstanceRetrievalService.java +++ b/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/instance/InstanceRetrievalService.java @@ -15,9 +15,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.netflix.appinfo.InstanceInfo; import com.netflix.discovery.converters.jackson.EurekaJsonJacksonCodec; +import com.netflix.discovery.shared.Application; import com.netflix.discovery.shared.Applications; import jakarta.validation.constraints.NotBlank; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.core5.http.Header; @@ -30,6 +32,7 @@ import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.zowe.apiml.apicatalog.discovery.DiscoveryConfigProperties; +import org.zowe.apiml.constants.EurekaMetadataDefinition; import org.zowe.apiml.message.log.ApimlLogger; import org.zowe.apiml.product.instance.InstanceInitializationException; import org.zowe.apiml.product.logging.annotations.InjectApimlLogger; @@ -37,9 +40,11 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Base64; -import java.util.List; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; + +import static org.zowe.apiml.product.constants.CoreService.GATEWAY; /** * Service for instance retrieval from Eureka @@ -58,6 +63,8 @@ public class InstanceRetrievalService { @InjectApimlLogger private final ApimlLogger apimlLog = ApimlLogger.empty(); + private ObjectMapper mapper = new ObjectMapper(); + @Autowired public InstanceRetrievalService(DiscoveryConfigProperties discoveryConfigProperties, CloseableHttpClient httpClient) { @@ -65,17 +72,7 @@ public InstanceRetrievalService(DiscoveryConfigProperties discoveryConfigPropert this.httpClient = httpClient; } - /** - * Retrieves {@link InstanceInfo} of particular service - * - * @param serviceId the service to search for - * @return service instance - */ - public InstanceInfo getInstanceInfo(@NotBlank(message = "Service Id must be supplied") String serviceId) { - if (serviceId.equalsIgnoreCase(UNKNOWN)) { - return null; - } - + private InstanceInfo getInstanceInfo(String serviceId, AtomicBoolean instanceFound, Predicate selector) { List eurekaServiceInstanceRequests = constructServiceInfoQueryRequest(serviceId, false); // iterate over list of discovery services, return at first success for (EurekaServiceInstanceRequest eurekaServiceInstanceRequest : eurekaServiceInstanceRequests) { @@ -83,15 +80,46 @@ public InstanceInfo getInstanceInfo(@NotBlank(message = "Service Id must be supp try { String responseBody = queryDiscoveryForInstances(eurekaServiceInstanceRequest); if (responseBody != null) { - return extractSingleInstanceFromApplication(serviceId, responseBody); + instanceFound.set(true); + return extractSingleInstanceFromApplication(serviceId, responseBody, selector); } } catch (Exception e) { log.debug("Error obtaining instance information from {}, error message: {}", eurekaServiceInstanceRequest.getEurekaRequestUrl(), e.getMessage()); } } - String msg = "An error occurred when trying to get instance info for: " + serviceId; - throw new InstanceInitializationException(msg); + return null; + } + + /** + * Retrieves {@link InstanceInfo} of particular service + * + * @param serviceId the service to search for + * @return service instance + */ + public InstanceInfo getInstanceInfo(@NotBlank(message = "Service Id must be supplied") String serviceId) { + if (serviceId.equalsIgnoreCase(UNKNOWN)) { + return null; + } + + // identification if there was no instance or any error happened during fetching + AtomicBoolean instanceFound = new AtomicBoolean(false); + InstanceInfo instanceInfo = getInstanceInfo(serviceId, instanceFound, + ii -> EurekaMetadataDefinition.RegistrationType.of(ii.getMetadata()).isPrimary() + ); + if (instanceInfo == null) { + // maybe the input is apimlId, try to find the matching Gateway (multi-tenancy use case) + instanceInfo = getInstanceInfo(GATEWAY.getServiceId(), instanceFound, + ii -> EurekaMetadataDefinition.RegistrationType.of(ii.getMetadata()).isAdditional() + ); + } + + if (!instanceFound.get()) { + String msg = "An error occurred when trying to get instance info for: " + serviceId; + throw new InstanceInitializationException(msg); + } + + return instanceInfo; } /** @@ -141,7 +169,7 @@ private Applications extractApplications(String responseBody) { * @param eurekaServiceInstanceRequest information used to query the discovery service * @return ResponseEntity query response */ - private String queryDiscoveryForInstances(EurekaServiceInstanceRequest eurekaServiceInstanceRequest) throws IOException { + String queryDiscoveryForInstances(EurekaServiceInstanceRequest eurekaServiceInstanceRequest) throws IOException { HttpGet httpGet = new HttpGet(eurekaServiceInstanceRequest.getEurekaRequestUrl()); for (Header header : createRequestHeader(eurekaServiceInstanceRequest)) { httpGet.setHeader(header); @@ -176,9 +204,8 @@ private String queryDiscoveryForInstances(EurekaServiceInstanceRequest eurekaSer * @param responseBody the fetch attempt response body * @return service instance */ - private InstanceInfo extractSingleInstanceFromApplication(String serviceId, String responseBody) { + InstanceInfo extractSingleInstanceFromApplication(String serviceId, String responseBody, Predicate selector) { ApplicationWrapper application = null; - ObjectMapper mapper = new ObjectMapper(); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); try { application = mapper.readValue(responseBody, ApplicationWrapper.class); @@ -186,15 +213,13 @@ private InstanceInfo extractSingleInstanceFromApplication(String serviceId, Stri log.debug("Could not extract service: {} info from discovery --{}", serviceId, e.getMessage(), e); } - - if (application != null - && application.getApplication() != null - && application.getApplication().getInstances() != null - && !application.getApplication().getInstances().isEmpty()) { - return application.getApplication().getInstances().get(0); - } else { - return null; - } + return Optional.ofNullable(application) + .map(ApplicationWrapper::getApplication) + .map(Application::getInstances) + .orElse(Collections.emptyList()) + .stream().filter(selector) + .findFirst() + .orElse(null); } /** @@ -221,7 +246,7 @@ private List constructServiceInfoQueryRequest(Stri log.debug("Querying instance information of the service {} from the URL {} with the user {} and password {}", serviceId, discoveryServiceLocatorUrl, eurekaUsername, - eurekaUserPassword.isEmpty() ? "NO PASSWORD" : "*******"); + StringUtils.isEmpty(eurekaUserPassword) ? "NO PASSWORD" : "*******"); EurekaServiceInstanceRequest eurekaServiceInstanceRequest = EurekaServiceInstanceRequest.builder() .serviceId(serviceId) diff --git a/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/services/cached/CachedProductFamilyService.java b/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/services/cached/CachedProductFamilyService.java index ef6b1006b5..03cb15e6dc 100644 --- a/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/services/cached/CachedProductFamilyService.java +++ b/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/services/cached/CachedProductFamilyService.java @@ -14,6 +14,7 @@ import com.netflix.discovery.shared.Application; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.zowe.apiml.apicatalog.model.APIContainer; @@ -24,7 +25,6 @@ import org.zowe.apiml.config.ApiInfo; import org.zowe.apiml.eurekaservice.client.util.EurekaMetadataParser; import org.zowe.apiml.message.log.ApimlLogger; -import org.zowe.apiml.product.constants.CoreService; import org.zowe.apiml.product.logging.annotations.InjectApimlLogger; import org.zowe.apiml.product.routing.RoutedServices; import org.zowe.apiml.product.routing.ServiceType; @@ -35,6 +35,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import static org.zowe.apiml.constants.EurekaMetadataDefinition.*; +import static org.zowe.apiml.product.constants.CoreService.GATEWAY; /** * Caching service for eureka services @@ -67,8 +68,7 @@ public class CachedProductFamilyService { public CachedProductFamilyService(CachedServicesService cachedServicesService, TransformService transformService, - @Value("${apiml.service-registry.cacheRefreshUpdateThresholdInMillis}") - Integer cacheRefreshUpdateThresholdInMillis, + @Value("${apiml.service-registry.cacheRefreshUpdateThresholdInMillis}") Integer cacheRefreshUpdateThresholdInMillis, CustomStyleConfig customStyleConfig) { this.cachedServicesService = cachedServicesService; this.transformService = transformService; @@ -275,7 +275,7 @@ private String getInstanceHomePageUrl(InstanceInfo instanceInfo) { String instanceHomePage = instanceInfo.getHomePageUrl(); //Gateway homePage is used to hold DVIPA address and must not be modified - if (hasHomePage(instanceInfo) && !isGateway(instanceInfo)) { + if (hasHomePage(instanceInfo) && !StringUtils.equalsIgnoreCase(GATEWAY.getServiceId(), instanceInfo.getAppName())) { instanceHomePage = instanceHomePage.trim(); RoutedServices routes = metadataParser.parseRoutes(instanceInfo.getMetadata()); try { @@ -326,10 +326,6 @@ private boolean hasHomePage(InstanceInfo instanceInfo) { && !instanceHomePage.isEmpty(); } - private boolean isGateway(InstanceInfo instanceInfo) { - return instanceInfo.getAppName().equalsIgnoreCase(CoreService.GATEWAY.getServiceId()); - } - /** * Create a new container based on information in a new instance * @@ -362,7 +358,7 @@ private APIContainer createNewContainerFromService(String productFamilyId, Insta * @param instanceInfo the service instance * @return a APIService object */ - private APIService createAPIServiceFromInstance(InstanceInfo instanceInfo) { + APIService createAPIServiceFromInstance(InstanceInfo instanceInfo) { boolean secureEnabled = instanceInfo.isPortEnabled(InstanceInfo.PortType.SECURE); String instanceHomePage = getInstanceHomePageUrl(instanceInfo); @@ -384,8 +380,21 @@ private APIService createAPIServiceFromInstance(InstanceInfo instanceInfo) { log.info("createApiServiceFromInstance#incorrectVersions {}", ex.getMessage()); } - return new APIService.Builder(instanceInfo.getAppName().toLowerCase()) - .title(instanceInfo.getMetadata().get(SERVICE_TITLE)) + String serviceId = instanceInfo.getAppName(); + String title = instanceInfo.getMetadata().get(SERVICE_TITLE); + if (StringUtils.equalsIgnoreCase(GATEWAY.getServiceId(), serviceId)) { + if (RegistrationType.of(instanceInfo.getMetadata()).isAdditional()) { + // additional registration for GW means domain one, update serviceId with the ApimlId + String apimlId = instanceInfo.getMetadata().get(APIML_ID); + if (apimlId != null) { + serviceId = apimlId; + title += " (" + apimlId + ")"; + } + } + } + + return new APIService.Builder(StringUtils.lowerCase(serviceId)) + .title(title) .description(instanceInfo.getMetadata().get(SERVICE_DESCRIPTION)) .secured(secureEnabled) .baseUrl(instanceInfo.getHomePageUrl()) diff --git a/api-catalog-services/src/test/java/org/zowe/apiml/apicatalog/instance/InstanceRetrievalServiceTest.java b/api-catalog-services/src/test/java/org/zowe/apiml/apicatalog/instance/InstanceRetrievalServiceTest.java index fc70b48ade..9675f70de0 100644 --- a/api-catalog-services/src/test/java/org/zowe/apiml/apicatalog/instance/InstanceRetrievalServiceTest.java +++ b/api-catalog-services/src/test/java/org/zowe/apiml/apicatalog/instance/InstanceRetrievalServiceTest.java @@ -22,6 +22,7 @@ import org.apache.hc.core5.http.io.HttpClientResponseHandler; import org.apache.hc.core5.http.io.entity.BasicHttpEntity; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -30,8 +31,10 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.util.ReflectionTestUtils; import org.zowe.apiml.apicatalog.discovery.DiscoveryConfigProperties; import org.zowe.apiml.apicatalog.util.ApplicationsWrapper; +import org.zowe.apiml.constants.EurekaMetadataDefinition; import org.zowe.apiml.product.constants.CoreService; import org.zowe.apiml.product.instance.InstanceInitializationException; import org.zowe.apiml.product.registry.ApplicationWrapper; @@ -47,199 +50,267 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.any; import static org.mockito.Mockito.*; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.APIML_ID; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.REGISTRATION_TYPE; -@ExtendWith(SpringExtension.class) -@TestPropertySource(locations = "/application.yml") -@ContextConfiguration(initializers = ConfigDataApplicationContextInitializer.class, classes = InstanceServicesContextConfiguration.class) class InstanceRetrievalServiceTest { private static final String UNKNOWN = "unknown"; - private InstanceRetrievalService instanceRetrievalService; - - @Autowired - private DiscoveryConfigProperties discoveryConfigProperties; - - @Mock - private CloseableHttpClient httpClient; - - @Mock - private CloseableHttpResponse response; - - @BeforeEach - void setup() { - HttpClientMockHelper.mockExecuteWithResponse(httpClient, response); - HttpClientMockHelper.mockResponse(response, HttpStatus.SC_OK); - - instanceRetrievalService = new InstanceRetrievalService(discoveryConfigProperties, httpClient); - } - - @Test - void whenDiscoveryServiceIsNotAvailable_thenTryOthersFromTheList() throws IOException { - when(response.getCode()).thenReturn(HttpStatus.SC_FORBIDDEN).thenReturn(HttpStatus.SC_OK); - - instanceRetrievalService.getAllInstancesFromDiscovery(false); - verify(httpClient, times(2)).execute(any(ClassicHttpRequest.class), any(HttpClientResponseHandler.class)); - } - - @Test - void testGetInstanceInfo_whenServiceIdIsUNKNOWN() { - InstanceInfo instanceInfo = instanceRetrievalService.getInstanceInfo(UNKNOWN); - assertNull(instanceInfo); - } - - @Test - void providedNoInstanceInfoIsReturned_thenInstanceInitializationExceptionIsThrown() { - String serviceId = CoreService.API_CATALOG.getServiceId(); - when(response.getCode()).thenReturn(HttpStatus.SC_FORBIDDEN); - - assertThrows(InstanceInitializationException.class, () -> instanceRetrievalService.getInstanceInfo(serviceId)); - } - - @Test - void testGetInstanceInfo_whenResponseHasEmptyBody() { - HttpClientMockHelper.mockResponse(response, ""); - InstanceInfo instanceInfo = instanceRetrievalService.getInstanceInfo(CoreService.API_CATALOG.getServiceId()); - assertNull(instanceInfo); + private InstanceInfo getStandardInstance(String serviceId, InstanceInfo.InstanceStatus status) { + return InstanceInfo.Builder.newBuilder() + .setInstanceId(serviceId) + .setAppName(serviceId) + .setStatus(status) + .build(); } - @Test - void testGetInstanceInfo_whenResponseCodeIsSuccessWithUnParsedJsonText() { - HttpClientMockHelper.mockResponse(response, "UNPARSABLE_JSON"); - InstanceInfo instanceInfo = instanceRetrievalService.getInstanceInfo(CoreService.API_CATALOG.getServiceId()); - assertNull(instanceInfo); - } + @Nested + @ExtendWith(SpringExtension.class) + @TestPropertySource(locations = "/application.yml") + @ContextConfiguration(initializers = ConfigDataApplicationContextInitializer.class, classes = InstanceServicesContextConfiguration.class) + class SingleDomain { + + private InstanceRetrievalService instanceRetrievalService; + + @Autowired + private DiscoveryConfigProperties discoveryConfigProperties; + + @Mock + private CloseableHttpClient httpClient; + + @Mock + private CloseableHttpResponse response; + + @BeforeEach + void setup() { + HttpClientMockHelper.mockExecuteWithResponse(httpClient, response); + HttpClientMockHelper.mockResponse(response, HttpStatus.SC_OK); + + instanceRetrievalService = new InstanceRetrievalService(discoveryConfigProperties, httpClient); + } + + @Test + void whenDiscoveryServiceIsNotAvailable_thenTryOthersFromTheList() throws IOException { + when(response.getCode()).thenReturn(HttpStatus.SC_FORBIDDEN).thenReturn(HttpStatus.SC_OK); + + instanceRetrievalService.getAllInstancesFromDiscovery(false); + verify(httpClient, times(2)).execute(any(ClassicHttpRequest.class), any(HttpClientResponseHandler.class)); + } + + @Test + void testGetInstanceInfo_whenServiceIdIsUNKNOWN() { + InstanceInfo instanceInfo = instanceRetrievalService.getInstanceInfo(UNKNOWN); + assertNull(instanceInfo); + } + + @Test + void providedNoInstanceInfoIsReturned_thenInstanceInitializationExceptionIsThrown() { + String serviceId = CoreService.API_CATALOG.getServiceId(); + when(response.getCode()).thenReturn(HttpStatus.SC_FORBIDDEN); + + assertThrows(InstanceInitializationException.class, () -> instanceRetrievalService.getInstanceInfo(serviceId)); + } + + @Test + void testGetInstanceInfo_whenResponseHasEmptyBody() { + HttpClientMockHelper.mockResponse(response, ""); + InstanceInfo instanceInfo = instanceRetrievalService.getInstanceInfo(CoreService.API_CATALOG.getServiceId()); + assertNull(instanceInfo); + } + + @Test + void testGetInstanceInfo_whenResponseCodeIsSuccessWithUnParsedJsonText() { + HttpClientMockHelper.mockResponse(response, "UNPARSABLE_JSON"); + InstanceInfo instanceInfo = instanceRetrievalService.getInstanceInfo(CoreService.API_CATALOG.getServiceId()); + assertNull(instanceInfo); + } + + @Test + void testGetInstanceInfo() throws IOException { + InstanceInfo expectedInstanceInfo = getStandardInstance( + CoreService.API_CATALOG.getServiceId(), + InstanceInfo.InstanceStatus.UP + ); - @Test - void testGetInstanceInfo() throws IOException { - InstanceInfo expectedInstanceInfo = getStandardInstance( - CoreService.API_CATALOG.getServiceId(), - InstanceInfo.InstanceStatus.UP - ); + ObjectMapper mapper = new ObjectMapper(); + String bodyCatalog = mapper.writeValueAsString( + new ApplicationWrapper(new Application( + CoreService.API_CATALOG.getServiceId(), + Collections.singletonList(expectedInstanceInfo) + ))); + BasicHttpEntity responseEntity = new BasicHttpEntity(IOUtils.toInputStream(bodyCatalog, StandardCharsets.UTF_8), APPLICATION_JSON); + when(response.getEntity()).thenReturn(responseEntity); - ObjectMapper mapper = new ObjectMapper(); - String bodyCatalog = mapper.writeValueAsString( - new ApplicationWrapper(new Application( - CoreService.API_CATALOG.getServiceId(), - Collections.singletonList(expectedInstanceInfo) - ))); - BasicHttpEntity responseEntity = new BasicHttpEntity(IOUtils.toInputStream(bodyCatalog, StandardCharsets.UTF_8), APPLICATION_JSON); - when(response.getEntity()).thenReturn(responseEntity); + InstanceInfo actualInstanceInfo = instanceRetrievalService.getInstanceInfo(CoreService.API_CATALOG.getServiceId()); - InstanceInfo actualInstanceInfo = instanceRetrievalService.getInstanceInfo(CoreService.API_CATALOG.getServiceId()); + assertNotNull(actualInstanceInfo); + assertThat(actualInstanceInfo, hasProperty("instanceId", equalTo(expectedInstanceInfo.getInstanceId()))); + assertThat(actualInstanceInfo, hasProperty("appName", equalTo(expectedInstanceInfo.getAppName()))); + assertThat(actualInstanceInfo, hasProperty("status", equalTo(expectedInstanceInfo.getStatus()))); + } - assertNotNull(actualInstanceInfo); - assertThat(actualInstanceInfo, hasProperty("instanceId", equalTo(expectedInstanceInfo.getInstanceId()))); - assertThat(actualInstanceInfo, hasProperty("appName", equalTo(expectedInstanceInfo.getAppName()))); - assertThat(actualInstanceInfo, hasProperty("status", equalTo(expectedInstanceInfo.getStatus()))); - } + @Test + void testGetAllInstancesFromDiscovery_whenResponseCodeIsNotSuccess() { + when(response.getCode()).thenReturn(HttpStatus.SC_FORBIDDEN); - @Test - void testGetAllInstancesFromDiscovery_whenResponseCodeIsNotSuccess() { - when(response.getCode()).thenReturn(HttpStatus.SC_FORBIDDEN); + Applications actualApplications = instanceRetrievalService.getAllInstancesFromDiscovery(false); + assertNull(actualApplications); + } - Applications actualApplications = instanceRetrievalService.getAllInstancesFromDiscovery(false); - assertNull(actualApplications); - } + @Test + void testGetAllInstancesFromDiscovery_whenResponseCodeIsSuccessWithUnParsedJsonText() { + Applications actualApplications = instanceRetrievalService.getAllInstancesFromDiscovery(false); + assertNull(actualApplications); + } - @Test - void testGetAllInstancesFromDiscovery_whenResponseCodeIsSuccessWithUnParsedJsonText() { - Applications actualApplications = instanceRetrievalService.getAllInstancesFromDiscovery(false); - assertNull(actualApplications); - } + @Test + void testGetAllInstancesFromDiscovery_whenNeedApplicationsWithoutFilter() throws IOException { + Map instanceInfoMap = createInstances(); - @Test - void testGetAllInstancesFromDiscovery_whenNeedApplicationsWithoutFilter() throws IOException { - Map instanceInfoMap = createInstances(); + Applications expectedApplications = new Applications(); + instanceInfoMap.forEach((key, value) -> expectedApplications.addApplication(new Application(value.getAppName(), Collections.singletonList(value)))); - Applications expectedApplications = new Applications(); - instanceInfoMap.forEach((key, value) -> expectedApplications.addApplication(new Application(value.getAppName(), Collections.singletonList(value)))); + ObjectMapper mapper = new ObjectMapper(); + String bodyAll = mapper.writeValueAsString(new ApplicationsWrapper(expectedApplications)); + BasicHttpEntity responseEntity = new BasicHttpEntity(IOUtils.toInputStream(bodyAll, StandardCharsets.UTF_8), APPLICATION_JSON); + when(response.getEntity()).thenReturn(responseEntity); - ObjectMapper mapper = new ObjectMapper(); - String bodyAll = mapper.writeValueAsString(new ApplicationsWrapper(expectedApplications)); - BasicHttpEntity responseEntity = new BasicHttpEntity(IOUtils.toInputStream(bodyAll, StandardCharsets.UTF_8), APPLICATION_JSON); - when(response.getEntity()).thenReturn(responseEntity); + Applications actualApplications = instanceRetrievalService.getAllInstancesFromDiscovery(false); - Applications actualApplications = instanceRetrievalService.getAllInstancesFromDiscovery(false); + assertEquals(expectedApplications.size(), actualApplications.size()); - assertEquals(expectedApplications.size(), actualApplications.size()); + List actualApplicationList = + new ArrayList<>(actualApplications.getRegisteredApplications()); - List actualApplicationList = - new ArrayList<>(actualApplications.getRegisteredApplications()); + expectedApplications + .getRegisteredApplications() + .forEach(expectedApplication -> + assertThat(actualApplicationList, hasItem(hasProperty("name", equalTo(expectedApplication.getName())))) + ); + } - expectedApplications - .getRegisteredApplications() - .forEach(expectedApplication -> - assertThat(actualApplicationList, hasItem(hasProperty("name", equalTo(expectedApplication.getName())))) - ); - } + @Test + void testGetAllInstancesFromDiscovery_whenNeedApplicationsWithDeltaFilter() throws IOException { + Map instanceInfoMap = createInstances(); - @Test - void testGetAllInstancesFromDiscovery_whenNeedApplicationsWithDeltaFilter() throws IOException { - Map instanceInfoMap = createInstances(); + Applications expectedApplications = new Applications(); + instanceInfoMap.forEach((key, value) -> expectedApplications.addApplication(new Application(value.getAppName(), Collections.singletonList(value)))); - Applications expectedApplications = new Applications(); - instanceInfoMap.forEach((key, value) -> expectedApplications.addApplication(new Application(value.getAppName(), Collections.singletonList(value)))); + ObjectMapper mapper = new ObjectMapper(); + String bodyAll = mapper.writeValueAsString(new ApplicationsWrapper(expectedApplications)); + BasicHttpEntity responseEntity = new BasicHttpEntity(IOUtils.toInputStream(bodyAll, StandardCharsets.UTF_8), APPLICATION_JSON); + when(response.getEntity()).thenReturn(responseEntity); - ObjectMapper mapper = new ObjectMapper(); - String bodyAll = mapper.writeValueAsString(new ApplicationsWrapper(expectedApplications)); - BasicHttpEntity responseEntity = new BasicHttpEntity(IOUtils.toInputStream(bodyAll, StandardCharsets.UTF_8), APPLICATION_JSON); - when(response.getEntity()).thenReturn(responseEntity); + Applications actualApplications = instanceRetrievalService.getAllInstancesFromDiscovery(true); - Applications actualApplications = instanceRetrievalService.getAllInstancesFromDiscovery(true); + assertEquals(expectedApplications.size(), actualApplications.size()); - assertEquals(expectedApplications.size(), actualApplications.size()); + List actualApplicationList = + new ArrayList<>(actualApplications.getRegisteredApplications()); - List actualApplicationList = - new ArrayList<>(actualApplications.getRegisteredApplications()); + expectedApplications + .getRegisteredApplications() + .forEach(expectedApplication -> + assertThat(actualApplicationList, hasItem(hasProperty("name", equalTo(expectedApplication.getName())))) + ); + } - expectedApplications - .getRegisteredApplications() - .forEach(expectedApplication -> - assertThat(actualApplicationList, hasItem(hasProperty("name", equalTo(expectedApplication.getName())))) - ); - } + private Map createInstances() { + Map instanceInfoMap = new HashMap<>(); - private Map createInstances() { - Map instanceInfoMap = new HashMap<>(); + InstanceInfo instanceInfo = getStandardInstance(CoreService.GATEWAY.getServiceId(), InstanceInfo.InstanceStatus.UP); + instanceInfoMap.put(instanceInfo.getAppName(), instanceInfo); - InstanceInfo instanceInfo = getStandardInstance(CoreService.GATEWAY.getServiceId(), InstanceInfo.InstanceStatus.UP); - instanceInfoMap.put(instanceInfo.getAppName(), instanceInfo); + instanceInfo = getStandardInstance(CoreService.ZAAS.getServiceId(), InstanceInfo.InstanceStatus.UP); + instanceInfoMap.put(instanceInfo.getAppName(), instanceInfo); - instanceInfo = getStandardInstance(CoreService.ZAAS.getServiceId(), InstanceInfo.InstanceStatus.UP); - instanceInfoMap.put(instanceInfo.getAppName(), instanceInfo); + instanceInfo = getStandardInstance(CoreService.API_CATALOG.getServiceId(), InstanceInfo.InstanceStatus.UP); + instanceInfoMap.put(instanceInfo.getAppName(), instanceInfo); - instanceInfo = getStandardInstance(CoreService.API_CATALOG.getServiceId(), InstanceInfo.InstanceStatus.UP); - instanceInfoMap.put(instanceInfo.getAppName(), instanceInfo); + instanceInfo = getStandardInstance("STATICCLIENT", InstanceInfo.InstanceStatus.UP); + instanceInfoMap.put(instanceInfo.getAppName(), instanceInfo); - instanceInfo = getStandardInstance("STATICCLIENT", InstanceInfo.InstanceStatus.UP); - instanceInfoMap.put(instanceInfo.getAppName(), instanceInfo); + instanceInfo = getStandardInstance("STATICCLIENT2", InstanceInfo.InstanceStatus.UP); + instanceInfoMap.put(instanceInfo.getAppName(), instanceInfo); - instanceInfo = getStandardInstance("STATICCLIENT2", InstanceInfo.InstanceStatus.UP); - instanceInfoMap.put(instanceInfo.getAppName(), instanceInfo); + instanceInfo = getStandardInstance("ZOSMF1", InstanceInfo.InstanceStatus.UP); + instanceInfoMap.put(instanceInfo.getAppName(), instanceInfo); - instanceInfo = getStandardInstance("ZOSMF1", InstanceInfo.InstanceStatus.UP); - instanceInfoMap.put(instanceInfo.getAppName(), instanceInfo); + instanceInfo = getStandardInstance("ZOSMF2", InstanceInfo.InstanceStatus.UP); + instanceInfoMap.put(instanceInfo.getAppName(), instanceInfo); - instanceInfo = getStandardInstance("ZOSMF2", InstanceInfo.InstanceStatus.UP); - instanceInfoMap.put(instanceInfo.getAppName(), instanceInfo); + return instanceInfoMap; + } - return instanceInfoMap; } - private InstanceInfo getStandardInstance(String serviceId, - InstanceInfo.InstanceStatus status) { + @Nested + class MultiDomain { + + private static final String APIML_CENTRAL = "apimlidcentral"; + private static final String APIML_ID_1 = "apimlid1"; + + private InstanceRetrievalService instanceRetrievalService; + private ObjectMapper mapper = mock(ObjectMapper.class); + + @BeforeEach + void init() throws IOException { + DiscoveryConfigProperties discoveryConfig = new DiscoveryConfigProperties(); + ReflectionTestUtils.setField(discoveryConfig, "locations", new String[] { "https://ds:10011/eureka" }); + + instanceRetrievalService = spy(new InstanceRetrievalService(discoveryConfig, null)); + ReflectionTestUtils.setField(instanceRetrievalService, "mapper", mapper); + + // construct Eureka representation of Gateway instances + InstanceInfo centralApiml = getStandardInstance(CoreService.GATEWAY.getServiceId(), InstanceInfo.InstanceStatus.UP); + ReflectionTestUtils.setField(centralApiml, "instanceId", "centralApiml:instance:1"); + centralApiml.getMetadata().put(APIML_ID, APIML_CENTRAL); + centralApiml.getMetadata().put(REGISTRATION_TYPE, EurekaMetadataDefinition.RegistrationType.PRIMARY.getValue()); + InstanceInfo apiml1 = getStandardInstance(CoreService.GATEWAY.getServiceId(), InstanceInfo.InstanceStatus.UP); + ReflectionTestUtils.setField(apiml1, "instanceId", "domainApiml:instance:1"); + apiml1.getMetadata().put(APIML_ID, APIML_ID_1); + apiml1.getMetadata().put(REGISTRATION_TYPE, EurekaMetadataDefinition.RegistrationType.ADDITIONAL.getValue()); + Application application = new Application(CoreService.GATEWAY.getServiceId()); + application.addInstance(centralApiml); + application.addInstance(apiml1); + ApplicationWrapper applications = new ApplicationWrapper(application); + + // mock obtaining and mapping of APIML instance + doReturn("gatewayJson").when(instanceRetrievalService).queryDiscoveryForInstances(argThat(request -> + CoreService.GATEWAY.getServiceId().equalsIgnoreCase(request.getServiceId()) + )); + doReturn(applications).when(mapper).readValue("gatewayJson", ApplicationWrapper.class); + } + + @Test + void givenAdditionalRegistrationOfGateway_whenAskWithApimlId_thenFindIt() { + InstanceInfo instanceInfo = instanceRetrievalService.getInstanceInfo(APIML_ID_1); + assertEquals(CoreService.GATEWAY.getServiceId(), instanceInfo.getAppName().toLowerCase(Locale.ROOT)); + assertEquals(APIML_ID_1, instanceInfo.getMetadata().get(APIML_ID)); + assertEquals(EurekaMetadataDefinition.RegistrationType.ADDITIONAL.getValue(), instanceInfo.getMetadata().get(REGISTRATION_TYPE)); + } + + @Test + void givenUnknownServiceId_whenGetInstanceInfo_thenMakeTwoQueries() throws IOException { + instanceRetrievalService.getInstanceInfo("unknown-service"); + verify(instanceRetrievalService, times(2)).queryDiscoveryForInstances(any()); + } + + @Test + void givenMultipleGateways_whenAskForGatewayService_thenReturnThePrimaryOne() { + InstanceInfo instanceInfo = instanceRetrievalService.getInstanceInfo(CoreService.GATEWAY.getServiceId()); + assertEquals(CoreService.GATEWAY.getServiceId(), instanceInfo.getAppName().toLowerCase(Locale.ROOT)); + assertEquals(APIML_CENTRAL, instanceInfo.getMetadata().get(APIML_ID)); + assertEquals(EurekaMetadataDefinition.RegistrationType.PRIMARY.getValue(), instanceInfo.getMetadata().get(REGISTRATION_TYPE)); + } - return InstanceInfo.Builder.newBuilder() - .setInstanceId(serviceId) - .setAppName(serviceId) - .setStatus(status) - .build(); } + } diff --git a/api-catalog-services/src/test/java/org/zowe/apiml/apicatalog/services/cached/CachedProductFamilyServiceTest.java b/api-catalog-services/src/test/java/org/zowe/apiml/apicatalog/services/cached/CachedProductFamilyServiceTest.java index 3b46722537..7728dff03e 100644 --- a/api-catalog-services/src/test/java/org/zowe/apiml/apicatalog/services/cached/CachedProductFamilyServiceTest.java +++ b/api-catalog-services/src/test/java/org/zowe/apiml/apicatalog/services/cached/CachedProductFamilyServiceTest.java @@ -21,6 +21,9 @@ import org.zowe.apiml.apicatalog.model.APIService; import org.zowe.apiml.apicatalog.model.CustomStyleConfig; import org.zowe.apiml.apicatalog.util.ServicesBuilder; +import org.zowe.apiml.product.constants.CoreService; +import org.zowe.apiml.product.gateway.GatewayClient; +import org.zowe.apiml.product.instance.ServiceAddress; import org.zowe.apiml.product.routing.RoutedServices; import org.zowe.apiml.product.routing.ServiceType; import org.zowe.apiml.product.routing.transform.TransformService; @@ -28,16 +31,12 @@ import java.util.*; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; -import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; - +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; import static org.zowe.apiml.constants.EurekaMetadataDefinition.*; /** @@ -514,4 +513,48 @@ void noServicesAreWithinTheContainer() { assertThat(underTest.getAllContainers().size(), is(0)); } } + + @Nested + class MultiTenancy { + + private CachedProductFamilyService cachedProductFamilyService; + + @BeforeEach + void init() { + cachedProductFamilyService = new CachedProductFamilyService( + new CachedServicesService(), + new TransformService(new GatewayClient(ServiceAddress.builder().scheme("https").hostname("localhost").build())), + 30000, + new CustomStyleConfig() + ); + } + + private APIService createDto(RegistrationType registrationType) { + Map metadata = new HashMap<>(); + metadata.put(APIML_ID, "apimlId"); + metadata.put(SERVICE_TITLE, "title"); + metadata.put(REGISTRATION_TYPE, registrationType.getValue()); + var service = InstanceInfo.Builder.newBuilder() + .setAppName(CoreService.GATEWAY.getServiceId()) + .setMetadata(metadata) + .build(); + return cachedProductFamilyService.createAPIServiceFromInstance(service); + } + + @Test + void givenPrimaryInstance_whenCreateDto_thenDoNotUpdateTitle() { + var dto = createDto(RegistrationType.ADDITIONAL); + assertEquals("title (apimlId)", dto.getTitle()); + assertEquals("apimlid", dto.getServiceId()); + } + + @Test + void givenPrimaryInstance_whenCreateDto_thenAddApimlIdIntoTitle() { + var dto = createDto(RegistrationType.PRIMARY); + assertEquals("title", dto.getTitle()); + assertEquals("gateway", dto.getServiceId()); + } + + } + } diff --git a/api-catalog-ui/frontend/cypress/e2e/dashboard/dashboard.cy.js b/api-catalog-ui/frontend/cypress/e2e/dashboard/dashboard.cy.js index 49145db06a..d848d57970 100644 --- a/api-catalog-ui/frontend/cypress/e2e/dashboard/dashboard.cy.js +++ b/api-catalog-ui/frontend/cypress/e2e/dashboard/dashboard.cy.js @@ -46,11 +46,11 @@ describe('>>> Dashboard test', () => { cy.get('#search > div > div > input').as('search').type('API Gateway'); - cy.get('.grid-tile').should('have.length', 1); + cy.get('.grid-tile').should('have.length', 2); cy.get('.clear-text-search').click(); - cy.get('.grid-tile').should('have.length.gte', 1); + cy.get('.grid-tile').should('have.length.gte', 2); cy.get('@search').should('have.text', ''); cy.contains('API Catalog').click(); diff --git a/api-catalog-ui/frontend/cypress/e2e/detail-page/detail-page.cy.js b/api-catalog-ui/frontend/cypress/e2e/detail-page/detail-page.cy.js index d7b5bd1696..56ab3dc48b 100644 --- a/api-catalog-ui/frontend/cypress/e2e/detail-page/detail-page.cy.js +++ b/api-catalog-ui/frontend/cypress/e2e/detail-page/detail-page.cy.js @@ -116,6 +116,6 @@ describe('>>> Detail page test', () => { cy.get('#search > div > div > input').as('search').type('API Gateway'); - cy.get('.grid-tile').should('have.length', 1).should('contain', 'API Gateway'); + cy.get('.grid-tile').should('have.length', 2).should('contain', 'API Gateway'); }); }); diff --git a/api-catalog-ui/frontend/cypress/e2e/detail-page/multiple-gateway-services.cy.js b/api-catalog-ui/frontend/cypress/e2e/detail-page/multiple-gateway-services.cy.js new file mode 100644 index 0000000000..2290286c6b --- /dev/null +++ b/api-catalog-ui/frontend/cypress/e2e/detail-page/multiple-gateway-services.cy.js @@ -0,0 +1,112 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ +/// + + +describe('>>> Multi-tenancy deployment test', () => { + it('Detail page test', () => { + cy.login(Cypress.env('username'), Cypress.env('password')); + + cy.get('#grid-container').contains('API Gateway (apiml2)').click(); + + cy.url().should('contain', '/service/apiml2'); + + cy.get('#go-back-button').should('exist'); + + cy.get('.api-description-container').should('exist'); + + cy.contains( + 'The API Mediation Layer for z/OS internal API services. The API Mediation Layer provides a single point of access to mainframe REST APIs and offers enterprise cloud-like features such as high-availability, scalability, dynamic API discovery, and documentation.' + ); + + cy.contains('Version: '); + }); + + it('Should display the additional API Gateway (apiml2) service title, URL and description in Swagger', () => { + cy.login(Cypress.env('username'), Cypress.env('password')); + + cy.contains('Version: '); + cy.get('#grid-container').contains('API Gateway (apiml2)').click(); + + cy.visit(`${Cypress.env('catalogHomePage')}/#/service/apiml2`); + + const baseUrl = `${Cypress.env('catalogHomePage')}`; + + cy.get( + '#swaggerContainer > div > div:nth-child(2) > div.scheme-container > section > div:nth-child(1) > div > div > label > select > option' + ) + .should('exist') + .should('contain', `${baseUrl.match(/^https?:\/\/([^/?#]+)(?:[/?#]|$)/i)[1]}/gateway/api/v1`); + + cy.get('.tabs-container').should('not.exist'); + cy.get('.serviceTab').should('exist').and('contain', 'API Gateway'); + + cy.contains('Service Homepage').should('exist'); + + cy.contains('Swagger/OpenAPI JSON Document').should('exist'); + + cy.get('.opblock-tag-section').should('have.length.gte', 1); + }); + + it('Should display the API Gateway service title, URL and description in Swagger', () => { + cy.login(Cypress.env('username'), Cypress.env('password')); + + cy.contains('Version: '); + cy.contains('API Gateway').click(); + + cy.visit(`${Cypress.env('catalogHomePage')}/#/service/gateway`); + + const baseUrl = `${Cypress.env('catalogHomePage')}`; + + cy.get( + '#swaggerContainer > div > div:nth-child(2) > div.scheme-container > section > div:nth-child(1) > div > div > label > select > option' + ) + .should('exist') + .should('contain', `${baseUrl.match(/^https?:\/\/([^/?#]+)(?:[/?#]|$)/i)[1]}/`); + + cy.get('.tabs-container').should('not.exist'); + cy.get('.serviceTab').should('exist').and('contain', 'API Gateway'); + + cy.contains('Service Homepage').should('exist'); + + cy.get('pre.version').should('contain', 'OAS'); + + cy.contains('Swagger/OpenAPI JSON Document').should('exist'); + + cy.get('.opblock-tag-section').should('have.length.gte', 1); + + cy.get( + '#root > div > div.content > div.main > div.main-content2.detail-content > div.content-description-container > div > div > div.header > h6:nth-child(4)' + ) + .should('exist') + .should( + 'contain', + 'API Gateway service to route requests to services registered in the API Mediation Layer and provides an API for mainframe security.' + ); + }); + + it('Should go to the detail page, go back to the dashboard page and check if the search bar works', () => { + cy.login(Cypress.env('username'), Cypress.env('password')); + + cy.contains('Version: '); + cy.contains('API Gateway').click(); + + cy.url().should('contain', '/service/gateway'); + + cy.get('#go-back-button').should('exist').click(); + + cy.get('#search > div > div > input').should('exist'); + cy.should('not.contain', 'Available API Services'); // Removed in last version + + cy.get('#search > div > div > input').as('search').type('API Gateway'); + + cy.get('.grid-tile').should('have.length', 2).should('contain', 'API Gateway'); + }); +}); diff --git a/api-catalog-ui/frontend/cypress/e2e/detail-page/service-version-compare.cy.js b/api-catalog-ui/frontend/cypress/e2e/detail-page/service-version-compare.cy.js index dc40a2223b..fabcba378a 100644 --- a/api-catalog-ui/frontend/cypress/e2e/detail-page/service-version-compare.cy.js +++ b/api-catalog-ui/frontend/cypress/e2e/detail-page/service-version-compare.cy.js @@ -36,7 +36,7 @@ describe('>>> Service version compare Test', () => { ); cy.get('div.MuiTabs-flexContainer.MuiTabs-flexContainerVertical') // Select the parent div .find('a.MuiTab-root') // Find all the anchor elements within the div - .should('have.length', 15); // Check if there are 15 anchor elements within the div + .should('have.length', 16); // Check if there are 15 anchor elements within the div cy.get('.version-text').should('exist'); cy.get('.version-text').should('contain.text', 'Compare'); }); diff --git a/apiml-common/src/main/java/org/zowe/apiml/product/instance/lookup/InstanceLookupExecutor.java b/apiml-common/src/main/java/org/zowe/apiml/product/instance/lookup/InstanceLookupExecutor.java index 847393d161..3548af17b6 100644 --- a/apiml-common/src/main/java/org/zowe/apiml/product/instance/lookup/InstanceLookupExecutor.java +++ b/apiml-common/src/main/java/org/zowe/apiml/product/instance/lookup/InstanceLookupExecutor.java @@ -15,9 +15,9 @@ import com.netflix.discovery.shared.Application; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.zowe.apiml.constants.EurekaMetadataDefinition; import org.zowe.apiml.product.instance.InstanceNotFoundException; -import java.util.List; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -36,12 +36,10 @@ private InstanceInfo findEurekaInstance(String serviceId) { throw new InstanceNotFoundException("Service '" + serviceId + "' is not registered to Discovery Service"); } - List appInstances = application.getInstances(); - if (appInstances.isEmpty()) { - throw new InstanceNotFoundException("'" + serviceId + "' has no running instances registered to Discovery Service"); - } - - return appInstances.get(0); + return application.getInstances().stream() + .filter(ii -> EurekaMetadataDefinition.RegistrationType.of(ii.getMetadata()).isPrimary()) + .findFirst() + .orElseThrow(() -> new InstanceNotFoundException("'" + serviceId + "' has no running instances registered to Discovery Service")); } /** diff --git a/apiml-common/src/test/java/org/zowe/apiml/product/instance/lookup/InstanceLookupExecutorTest.java b/apiml-common/src/test/java/org/zowe/apiml/product/instance/lookup/InstanceLookupExecutorTest.java index ad4cc0a0ee..c5fc6aa399 100644 --- a/apiml-common/src/test/java/org/zowe/apiml/product/instance/lookup/InstanceLookupExecutorTest.java +++ b/apiml-common/src/test/java/org/zowe/apiml/product/instance/lookup/InstanceLookupExecutorTest.java @@ -14,174 +14,231 @@ import com.netflix.discovery.EurekaClient; import com.netflix.discovery.shared.Application; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.zowe.apiml.constants.EurekaMetadataDefinition; import org.zowe.apiml.product.constants.CoreService; import org.zowe.apiml.product.instance.InstanceInitializationException; import org.zowe.apiml.product.instance.InstanceNotFoundException; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; +import java.util.*; import java.util.concurrent.CountDownLatch; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import static java.time.Duration.ofMillis; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.REGISTRATION_TYPE; -@ExtendWith(MockitoExtension.class) class InstanceLookupExecutorTest { - private static final String SERVICE_ID = CoreService.API_CATALOG.getServiceId(); + @Nested + @ExtendWith(MockitoExtension.class) + class UsageInApiCatalog { + + private static final String SERVICE_ID = CoreService.API_CATALOG.getServiceId(); + + @Mock + private EurekaClient eurekaClient; + + private InstanceLookupExecutor instanceLookupExecutor; + private List instances; + + private Exception lastException; + private InstanceInfo lastInstanceInfo; + private CountDownLatch latch; + + private InstanceInfo getInstance(String serviceId) { + return createInstance( + serviceId, + serviceId, + InstanceInfo.InstanceStatus.UP, + InstanceInfo.ActionType.ADDED, + new HashMap<>()); + } + + InstanceInfo createInstance(String serviceId, String instanceId, + InstanceInfo.InstanceStatus status, + InstanceInfo.ActionType actionType, + HashMap metadata) { + return InstanceInfo.Builder.newBuilder() + .setInstanceId(instanceId) + .setAppName(serviceId.toUpperCase()) + .setIPAddr("192.168.0.1") + .setSecurePort(9090) + .setHostName("hostname") + .setStatus(status) + .setMetadata(metadata) + .setActionType(actionType) + .build(); + } + + @BeforeEach + void setUp() { + instanceLookupExecutor = new InstanceLookupExecutor(eurekaClient); + instances = Collections.singletonList( + getInstance(SERVICE_ID)); + latch = new CountDownLatch(1); + } + + @Test + void testRun_whenNoApplicationRegisteredInDiscovery() { + assertTimeout(ofMillis(2000), () -> { + instanceLookupExecutor.run( + SERVICE_ID, null, + (exception, isStopped) -> { + lastException = exception; + latch.countDown(); + } + ); + + latch.await(); + }); + + assertNotNull(lastException); + assertInstanceOf(InstanceNotFoundException.class, lastException); + assertEquals("Service '" + SERVICE_ID + "' is not registered to Discovery Service", + lastException.getMessage()); + } + + @Test + void testRun_whenNoInstancesExistInDiscovery() { + assertTimeout(ofMillis(2000), () -> { + when(eurekaClient.getApplication(SERVICE_ID)) + .thenReturn(new Application(SERVICE_ID, Collections.emptyList())); + + instanceLookupExecutor.run( + SERVICE_ID, null, + (exception, isStopped) -> { + lastException = exception; + latch.countDown(); + } + ); + + latch.await(); + }); + + assertNotNull(lastException); + assertInstanceOf(InstanceNotFoundException.class, lastException); + assertEquals("'" + SERVICE_ID + "' has no running instances registered to Discovery Service", + lastException.getMessage()); + } + + @Test + void testRun_whenUnexpectedExceptionHappened() { + assertTimeout(ofMillis(2000), () -> { + when(eurekaClient.getApplication(SERVICE_ID)) + .thenThrow(new InstanceInitializationException("Unexpected Exception")); + + instanceLookupExecutor.run( + SERVICE_ID, null, + (exception, isStopped) -> { + lastException = exception; + latch.countDown(); + } + ); + + latch.await(); + }); + + assertNotNull(lastException); + assertInstanceOf(InstanceInitializationException.class, lastException); + } + + @Test + void testRun_whenInstanceExistInDiscovery() { + assertTimeout(ofMillis(2000), () -> { + when(eurekaClient.getApplication(SERVICE_ID)) + .thenReturn(new Application(SERVICE_ID, instances)); + + instanceLookupExecutor.run( + SERVICE_ID, + instanceInfo -> { + lastInstanceInfo = instanceInfo; + latch.countDown(); + }, null + ); + + latch.await(); + }); + + assertNull(lastException); + assertNotNull(lastInstanceInfo); + assertEquals(instances.get(0), lastInstanceInfo); + } - @Mock - private EurekaClient eurekaClient; - - private InstanceLookupExecutor instanceLookupExecutor; - private List instances; - - private Exception lastException; - private InstanceInfo lastInstanceInfo; - private CountDownLatch latch; - - @BeforeEach - void setUp() { - instanceLookupExecutor = new InstanceLookupExecutor(eurekaClient); - instances = Collections.singletonList( - getInstance(SERVICE_ID)); - latch = new CountDownLatch(1); } + @Nested + class MultiTenancy { + + private final EurekaClient eurekaClient = mock(EurekaClient.class); + private final InstanceLookupExecutor instanceLookupExecutor = new InstanceLookupExecutor(eurekaClient); + + private final Consumer successHandler = mock(Consumer.class); + private final BiConsumer failureHandler = mock(BiConsumer.class); + + private final Application application = new Application(CoreService.GATEWAY.getServiceId()); + + InstanceInfo mockGateway(EurekaMetadataDefinition.RegistrationType registrationType) { + Map metadata = new HashMap<>(); + if (registrationType != null) { + metadata.put(REGISTRATION_TYPE, registrationType.getValue()); + } + InstanceInfo instanceInfo = InstanceInfo.Builder.newBuilder() + .setAppName(CoreService.GATEWAY.getServiceId()) + .setInstanceId(CoreService.GATEWAY.getServiceId() + ":localhost:" + (1 + new Random().nextInt() % 65535)) + .setMetadata(metadata) + .build(); + application.addInstance(instanceInfo); + return instanceInfo; + } + + private void invokeRun() { + instanceLookupExecutor.run(CoreService.GATEWAY.getServiceId(), successHandler, failureHandler); + } + + @BeforeEach + void setUp() { + doReturn(application).when(eurekaClient).getApplication(CoreService.GATEWAY.getServiceId()); + } + + @Test + void givenNoInstance_whenRun_thenInvokeFailureHandler() { + invokeRun(); + verify(successHandler, never()).accept(any()); + verify(failureHandler).accept(any(), eq(false)); + } + + @Test + void givenOnlyAdditionalInstances_whenRun_thenInvokeFailureHandler() { + mockGateway(EurekaMetadataDefinition.RegistrationType.ADDITIONAL); + invokeRun(); + verify(successHandler, never()).accept(any()); + verify(failureHandler).accept(any(), eq(false)); + } + + @Test + void givenOnlyPrimaryInstances_whenRun_thenInvokeSuccessHandler() { + var primary = mockGateway(EurekaMetadataDefinition.RegistrationType.PRIMARY); + invokeRun(); + verify(successHandler).accept(primary); + verify(failureHandler, never()).accept(any(), anyBoolean()); + } + + @Test + void givenMultipleInstances_whenRun_thenInvokeSuccessHandler() { + var primary = mockGateway(EurekaMetadataDefinition.RegistrationType.PRIMARY); + mockGateway(EurekaMetadataDefinition.RegistrationType.ADDITIONAL); + invokeRun(); + verify(successHandler).accept(primary); + verify(failureHandler, never()).accept(any(), anyBoolean()); + } - @Test - void testRun_whenNoApplicationRegisteredInDiscovery() throws InterruptedException { - assertTimeout(ofMillis(2000), () -> { - instanceLookupExecutor.run( - SERVICE_ID, null, - (exception, isStopped) -> { - lastException = exception; - latch.countDown(); - } - ); - - latch.await(); - }); - - assertNotNull(lastException); - assertTrue(lastException instanceof InstanceNotFoundException); - assertEquals("Service '" + SERVICE_ID + "' is not registered to Discovery Service", - lastException.getMessage()); - } - - - @Test - void testRun_whenNoInstancesExistInDiscovery() throws InterruptedException { - assertTimeout(ofMillis(2000), () -> { - when(eurekaClient.getApplication(SERVICE_ID)) - .thenReturn(new Application(SERVICE_ID, Collections.emptyList())); - - instanceLookupExecutor.run( - SERVICE_ID, null, - (exception, isStopped) -> { - lastException = exception; - latch.countDown(); - } - ); - - latch.await(); - }); - - assertNotNull(lastException); - assertTrue(lastException instanceof InstanceNotFoundException); - assertEquals("'" + SERVICE_ID + "' has no running instances registered to Discovery Service", - lastException.getMessage()); } - @Test - void testRun_whenUnexpectedExceptionHappened() throws InterruptedException { - assertTimeout(ofMillis(2000), () -> { - when(eurekaClient.getApplication(SERVICE_ID)) - .thenThrow(new InstanceInitializationException("Unexpected Exception")); - - instanceLookupExecutor.run( - SERVICE_ID, null, - (exception, isStopped) -> { - lastException = exception; - latch.countDown(); - } - ); - - latch.await(); - }); - - assertNotNull(lastException); - assertTrue(lastException instanceof InstanceInitializationException); - } - - @Test - void testRun_whenInstanceExistInDiscovery() throws InterruptedException { - assertTimeout(ofMillis(2000), () -> { - when(eurekaClient.getApplication(SERVICE_ID)) - .thenReturn(new Application(SERVICE_ID, instances)); - - instanceLookupExecutor.run( - SERVICE_ID, - instanceInfo -> { - lastInstanceInfo = instanceInfo; - latch.countDown(); - }, null - ); - - latch.await(); - }); - - assertNull(lastException); - assertNotNull(lastInstanceInfo); - assertEquals(instances.get(0), lastInstanceInfo); - } - - - private InstanceInfo getInstance(String serviceId) { - return createInstance( - serviceId, - serviceId, - InstanceInfo.InstanceStatus.UP, - InstanceInfo.ActionType.ADDED, - new HashMap<>()); - } - - InstanceInfo createInstance(String serviceId, String instanceId, - InstanceInfo.InstanceStatus status, - InstanceInfo.ActionType actionType, - HashMap metadata) { - return new InstanceInfo( - instanceId, - serviceId.toUpperCase(), - null, - "192.168.0.1", - null, - new InstanceInfo.PortWrapper(true, 9090), - null, - null, - null, - null, - null, - null, - null, - 0, - null, - "hostname", - status, - null, - null, - null, - null, - metadata, - null, - null, - actionType, - null); - } } diff --git a/common-service-core/src/main/java/org/zowe/apiml/constants/EurekaMetadataDefinition.java b/common-service-core/src/main/java/org/zowe/apiml/constants/EurekaMetadataDefinition.java index 23f002d9e9..66dfb69965 100644 --- a/common-service-core/src/main/java/org/zowe/apiml/constants/EurekaMetadataDefinition.java +++ b/common-service-core/src/main/java/org/zowe/apiml/constants/EurekaMetadataDefinition.java @@ -10,6 +10,13 @@ package org.zowe.apiml.constants; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; + +import java.util.Map; +import java.util.Optional; + public final class EurekaMetadataDefinition { private EurekaMetadataDefinition() { @@ -35,6 +42,7 @@ private EurekaMetadataDefinition() { public static final String SERVICE_SUPPORTING_CLIENT_CERT_FORWARDING = "apiml.service.supportClientCertForwarding"; public static final String ENABLE_URL_ENCODED_CHARACTERS = "apiml.enableUrlEncodedCharacters"; public static final String APIML_ID = "apiml.service.apimlId"; + public static final String REGISTRATION_TYPE = "apiml.registrationType"; public static final String API_INFO = "apiml.apiInfo"; public static final String API_INFO_API_ID = "apiId"; @@ -72,4 +80,55 @@ private EurekaMetadataDefinition() { public static final String API_VERSION_PROPERTIES_TITLE_V1 = "mfaas.api-info.apiVersionProperties.v1.title"; public static final String API_VERSION_PROPERTIES_VERSION_V1 = "mfaas.api-info.apiVersionProperties.v1.version"; public static final String API_VERSION_PROPERTIES_DESCRIPTION_V1 = "mfaas.api-info.apiVersionProperties.v1.description"; + + public interface BasicRegistrationType { + + default boolean isAdditional() { + return false; + } + + default boolean isPrimary() { + return false; + } + + } + + @RequiredArgsConstructor + public enum RegistrationType implements BasicRegistrationType { + PRIMARY("primary"), + ADDITIONAL("additional") + ; + + @Getter + private final String value; + + public static RegistrationType of(String value) { + for (RegistrationType registrationType : RegistrationType.values()) { + if (StringUtils.equals(value, registrationType.getValue())) { + return registrationType; + } + } + if (value == null) { + return PRIMARY; + } + return null; + } + + public static BasicRegistrationType of(Map metadata) { + return Optional.ofNullable(metadata) + .map(m -> of(m.get(REGISTRATION_TYPE))) + .map(BasicRegistrationType.class::cast) + .orElse(new BasicRegistrationType() {}); + } + + public boolean isAdditional() { + return this == ADDITIONAL; + } + + public boolean isPrimary() { + return this == PRIMARY; + } + + } + } diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java index 1013dc5879..2c13fb4839 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/config/ConnectionsConfig.java @@ -23,6 +23,7 @@ import io.netty.resolver.DefaultAddressResolverGroup; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.springframework.aop.support.AopUtils; import org.springframework.beans.BeanUtils; import org.springframework.beans.BeansException; @@ -52,12 +53,14 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.util.CollectionUtils; import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.pattern.PathPatternParser; import org.zowe.apiml.config.AdditionalRegistration; import org.zowe.apiml.config.AdditionalRegistrationCondition; import org.zowe.apiml.config.AdditionalRegistrationParser; +import org.zowe.apiml.constants.EurekaMetadataDefinition; import org.zowe.apiml.message.log.ApimlLogger; import org.zowe.apiml.message.yaml.YamlMessageServiceInstance; import org.zowe.apiml.security.HttpsConfig; @@ -77,8 +80,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import static org.springframework.cloud.netflix.eureka.EurekaClientConfigBean.DEFAULT_ZONE; +import static org.zowe.apiml.constants.EurekaMetadataDefinition.*; //TODO this configuration should be removed as redundancy of the HttpConfig in the apiml-common @@ -314,7 +319,6 @@ public AdditionalEurekaClientsHolder additionalEurekaClientsHolder(ApplicationIn } private CloudEurekaClient registerInTheApimlInstance(EurekaClientConfig config, AdditionalRegistration apimlRegistration, ApplicationInfoManager appManager, EurekaFactory eurekaFactory) { - log.debug("additional registration: {}", apimlRegistration.getDiscoveryServiceUrls()); Map urls = new HashMap<>(); urls.put(DEFAULT_ZONE, apimlRegistration.getDiscoveryServiceUrls()); @@ -325,11 +329,41 @@ private CloudEurekaClient registerInTheApimlInstance(EurekaClientConfig config, EurekaInstanceConfig eurekaInstanceConfig = appManager.getEurekaInstanceConfig(); InstanceInfo newInfo = eurekaFactory.createInstanceInfo(eurekaInstanceConfig); + + updateMetadata(newInfo, apimlRegistration); + RestTemplateDiscoveryClientOptionalArgs args1 = defaultArgs(getDefaultEurekaClientHttpRequestFactorySupplier()); RestTemplateTransportClientFactories factories = new RestTemplateTransportClientFactories(args1); return eurekaFactory.createCloudEurekaClient(eurekaInstanceConfig, newInfo, configBean, context, factories, args1); } + private boolean isRouteKey(String key) { + return StringUtils.startsWith(key, ROUTES + ".") && + ( + StringUtils.endsWith(key, "." + ROUTES_GATEWAY_URL) || + StringUtils.endsWith(key, "." + ROUTES_SERVICE_URL) + ); + } + + private void updateMetadata(InstanceInfo instanceInfo, AdditionalRegistration additionalRegistration) { + var metadata = instanceInfo.getMetadata(); + metadata.put(REGISTRATION_TYPE, EurekaMetadataDefinition.RegistrationType.ADDITIONAL.getValue()); + + // if routes were override replace them in the map, otherwise use the default from the primary registration + if (!CollectionUtils.isEmpty(additionalRegistration.getRoutes())) { + // remove current routes + var currentRoutes = metadata.keySet().stream().filter(this::isRouteKey).collect(Collectors.toSet()); + currentRoutes.forEach(metadata::remove); + + // generate new routes metadata + int index = 0; + for (var route : additionalRegistration.getRoutes()) { + metadata.put(String.format("apiml.routes.%d.gatewayUrl", index), route.getGatewayUrl()); + metadata.put(String.format("apiml.routes.%d.serviceUrl", index++), route.getServiceUrl()); + } + } + } + @Bean public Customizer defaultCustomizer() { return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id) diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/controllers/GatewayExceptionHandler.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/controllers/GatewayExceptionHandler.java index 895b8e7fc1..55650151c9 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/controllers/GatewayExceptionHandler.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/controllers/GatewayExceptionHandler.java @@ -163,7 +163,7 @@ public Mono handleStatusError(ServerWebExchange exchange, ResponseStatusEx @ExceptionHandler({ServiceNotAccessibleException.class, WebClientResponseException.ServiceUnavailable.class}) public Mono handleServiceNotAccessibleException(ServerWebExchange exchange, Exception ex) { log.debug("A service is not available at the moment to finish request {}: {}", exchange.getRequest().getURI(), ex.getMessage()); - return setBodyResponse(exchange, SC_SERVICE_UNAVAILABLE, "org.zowe.apiml.common.serviceUnavailable"); + return setBodyResponse(exchange, SC_SERVICE_UNAVAILABLE, "org.zowe.apiml.common.serviceUnavailable", exchange.getRequest().getURI()); } } diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index d952d8137a..31c595c6c2 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -11,6 +11,7 @@ eureka: securePortEnabled: ${apiml.service.securePortEnabled} metadata-map: apiml: + registrationType: primary apiBasePath: /gateway/api/v1 catalog: tile: diff --git a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/AdditionalRegistrationTest.java b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/AdditionalRegistrationTest.java index b62342a49e..fc55cc9b25 100644 --- a/gateway-service/src/test/java/org/zowe/apiml/gateway/config/AdditionalRegistrationTest.java +++ b/gateway-service/src/test/java/org/zowe/apiml/gateway/config/AdditionalRegistrationTest.java @@ -34,15 +34,15 @@ import org.zowe.apiml.security.HttpsFactory; import java.util.AbstractMap; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; import static java.util.Arrays.asList; import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import static org.springframework.cloud.netflix.eureka.EurekaClientConfigBean.DEFAULT_ZONE; @ExtendWith(MockitoExtension.class) @@ -76,20 +76,29 @@ class WhenInitializingAdditionalRegistrations { @Mock private HttpsFactory httpsFactory; + private InstanceInfo instanceInfo; + @Captor private ArgumentCaptor clientConfigCaptor; - private final AdditionalRegistration registration = AdditionalRegistration.builder().discoveryServiceUrls("https://another-eureka-1").build(); + private List routes = Arrays.asList(new AdditionalRegistration.Route("/", "/")); + private final AdditionalRegistration registration = AdditionalRegistration.builder().discoveryServiceUrls("https://another-eureka-1").routes(routes).build(); @BeforeEach public void setUp() throws Exception { - ReflectionTestUtils.setField(connectionsConfig,"eurekaServerUrl","https://host:2222"); - ReflectionTestUtils.setField(connectionsConfig,"httpsFactory",httpsFactory); + ReflectionTestUtils.setField(connectionsConfig, "eurekaServerUrl", "https://host:2222"); + ReflectionTestUtils.setField(connectionsConfig, "httpsFactory", httpsFactory); configSpy = Mockito.spy(connectionsConfig); lenient().doReturn(httpsFactory).when(configSpy).factory(); lenient().when(httpsFactory.getSslContext()).thenReturn(SSLContexts.custom().build()); lenient().when(httpsFactory.getHostnameVerifier()).thenReturn(new NoopHostnameVerifier()); lenient().when(eurekaFactory.createCloudEurekaClient(any(), any(), clientConfigCaptor.capture(), any(), any(), any())).thenReturn(additionalClientOne, additionalClientTwo); + + var metadata = new HashMap(); + metadata.put("apiml.routes.0.gatewayUrl", "/api/v1"); + metadata.put("apiml.routes.0.serviceUrl", "/service/api/v1"); + instanceInfo = InstanceInfo.Builder.newBuilder().setAppName("service1").setMetadata(metadata).build(); + lenient().when(eurekaFactory.createInstanceInfo(any())).thenReturn(instanceInfo); } @Test @@ -110,6 +119,9 @@ void shouldCreateTwoAdditionalRegistrations() { assertThat(holder.getDiscoveryClients()).hasSize(2); verify(additionalClientOne).registerHealthCheck(healthCheckHandler); verify(additionalClientTwo).registerHealthCheck(healthCheckHandler); + + assertThat(instanceInfo.getMetadata().get("apiml.routes.0.gatewayUrl")).isEqualTo("/"); + assertThat(instanceInfo.getMetadata().get("apiml.routes.0.serviceUrl")).isEqualTo("/"); } @Test diff --git a/integration-tests/src/test/resources/environment-configuration-ha.yml b/integration-tests/src/test/resources/environment-configuration-ha.yml index b45f144363..27a4d6b0bd 100644 --- a/integration-tests/src/test/resources/environment-configuration-ha.yml +++ b/integration-tests/src/test/resources/environment-configuration-ha.yml @@ -7,7 +7,6 @@ gatewayServiceConfiguration: scheme: https host: gateway-service,gateway-service-2 port: 10010 - internalPorts: 10017,10027 externalPort: 10010 instances: 2 servicesEndpoint: gateway/api/v1/services