diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/CacheableApplicationJson.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/CacheableApplicationJson.java new file mode 100644 index 000000000000..87d58528a921 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/CacheableApplicationJson.java @@ -0,0 +1,13 @@ +package com.appsmith.server.dtos; + +import lombok.Data; + +import java.time.Instant; + +@Data +public class CacheableApplicationJson { + + ApplicationJson applicationJson; + + Instant cacheExpiryTime; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/CacheableApplicationTemplate.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/CacheableApplicationTemplate.java new file mode 100644 index 000000000000..84f1b495422b --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/CacheableApplicationTemplate.java @@ -0,0 +1,14 @@ +package com.appsmith.server.dtos; + +import lombok.Data; + +import java.time.Instant; +import java.util.List; + +@Data +public class CacheableApplicationTemplate { + + List applicationTemplateList; + + Instant cacheExpiryTime; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java index f289ab9925cd..32adf1683f55 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithError.java @@ -645,7 +645,7 @@ public enum AppsmithError { ErrorType.CONFIGURATION_ERROR, null), CLOUD_SERVICES_ERROR( - 500, + 400, AppsmithErrorCode.CLOUD_SERVICES_ERROR.getCode(), "Received error from cloud services {0}", AppsmithErrorAction.DEFAULT, diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/CacheableTemplateHelper.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/CacheableTemplateHelper.java new file mode 100644 index 000000000000..88c58832d606 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/CacheableTemplateHelper.java @@ -0,0 +1,147 @@ +package com.appsmith.server.helpers; + +import com.appsmith.external.converters.ISOStringToInstantConverter; +import com.appsmith.server.dtos.ApplicationJson; +import com.appsmith.server.dtos.ApplicationTemplate; +import com.appsmith.server.dtos.CacheableApplicationJson; +import com.appsmith.server.dtos.CacheableApplicationTemplate; +import com.appsmith.server.exceptions.AppsmithError; +import com.appsmith.server.exceptions.AppsmithException; +import com.appsmith.server.services.ce.ApplicationTemplateServiceCEImpl; +import com.appsmith.util.WebClientUtils; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.lang.reflect.Type; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Component +public class CacheableTemplateHelper { + // Template metadata is used for showing the preview of the template + + CacheableApplicationTemplate applicationTemplateList = new CacheableApplicationTemplate(); + + Map cacheableApplicationJsonMap = new HashMap<>(); + private static final int CACHE_LIFE_TIME_IN_SECONDS = 60 * 60 * 24; // 24 hours + + public Mono getTemplates(String releaseVersion, String baseUrl) { + + if (applicationTemplateList == null) { + applicationTemplateList = new CacheableApplicationTemplate(); + } + + if (applicationTemplateList.getCacheExpiryTime() != null + && isCacheValid(applicationTemplateList.getCacheExpiryTime())) { + return Mono.just(applicationTemplateList); + } + + UriComponentsBuilder uriComponentsBuilder = + UriComponentsBuilder.newInstance().queryParam("version", releaseVersion); + + // uriComponents will build url in format: version=version&id=id1&id=id2&id=id3 + UriComponents uriComponents = uriComponentsBuilder.build(); + + return WebClientUtils.create(baseUrl + "/api/v1/app-templates?" + uriComponents.getQuery()) + .get() + .exchangeToFlux(clientResponse -> { + if (clientResponse.statusCode().equals(HttpStatus.OK)) { + return clientResponse.bodyToFlux(ApplicationTemplate.class); + } else if (clientResponse.statusCode().isError()) { + log.error("Error fetching templates from cloud services. Status code: {}", clientResponse); + return Flux.error( + new AppsmithException(AppsmithError.CLOUD_SERVICES_ERROR, clientResponse.statusCode())); + } else { + return clientResponse.createException().flatMapMany(Flux::error); + } + }) + .collectList() + .map(applicationTemplates -> { + applicationTemplateList.setApplicationTemplateList(applicationTemplates); + applicationTemplateList.setCacheExpiryTime(Instant.now()); + return applicationTemplateList; + }); + } + + // Actual JSON object of the template + public Mono getApplicationByTemplateId(String templateId, String baseUrl) { + final String templateUrl = baseUrl + "/api/v1/app-templates/" + templateId + "/application"; + /* + * using a custom url builder factory because default builder always encodes + * URL. + * It's expected that the appDataUrl is already encoded, so we don't need to + * encode that again. + * Encoding an encoded URL will not work and end up resulting a 404 error + */ + final int size = 4 * 1024 * 1024; // 4 MB + + if (cacheableApplicationJsonMap == null) { + cacheableApplicationJsonMap = new HashMap<>(); + } + + if (cacheableApplicationJsonMap.containsKey(templateId) + && isCacheValid(cacheableApplicationJsonMap.get(templateId).getCacheExpiryTime())) { + return Mono.just(getCacheableApplicationJsonCopy(cacheableApplicationJsonMap.get(templateId))); + } + + final ExchangeStrategies strategies = ExchangeStrategies.builder() + .codecs(codecs -> codecs.defaultCodecs().maxInMemorySize(size)) + .build(); + + WebClient webClient = WebClientUtils.builder() + .uriBuilderFactory(new ApplicationTemplateServiceCEImpl.NoEncodingUriBuilderFactory(templateUrl)) + .exchangeStrategies(strategies) + .build(); + + return webClient + .get() + .retrieve() + .bodyToMono(String.class) + .map(jsonString -> { + Gson gson = getGson(); + Type fileType = new TypeToken() {}.getType(); + + CacheableApplicationJson cacheableApplicationJson = new CacheableApplicationJson(); + cacheableApplicationJson.setApplicationJson(gson.fromJson(jsonString, fileType)); + cacheableApplicationJson.setCacheExpiryTime(Instant.now()); + + // Remove/replace the value from cache + cacheableApplicationJsonMap.put(templateId, cacheableApplicationJson); + return getCacheableApplicationJsonCopy(cacheableApplicationJson); + }) + .switchIfEmpty( + Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, "template", templateId))); + } + + private CacheableApplicationJson getCacheableApplicationJsonCopy(CacheableApplicationJson src) { + Gson gson = getGson(); + return gson.fromJson(gson.toJson(src), CacheableApplicationJson.class); + } + + @NotNull private Gson getGson() { + return new GsonBuilder() + .registerTypeAdapter(Instant.class, new ISOStringToInstantConverter()) + .create(); + } + + public boolean isCacheValid(Instant lastUpdatedAt) { + return Instant.now().minusSeconds(CACHE_LIFE_TIME_IN_SECONDS).isBefore(lastUpdatedAt); + } + + public Map getCacheableApplicationJsonMap() { + return cacheableApplicationJsonMap; + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/imports/internal/partial/PartialImportServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/imports/internal/partial/PartialImportServiceCEImpl.java index 4e26550cc924..c3dc7551c55c 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/imports/internal/partial/PartialImportServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/imports/internal/partial/PartialImportServiceCEImpl.java @@ -1,6 +1,7 @@ package com.appsmith.server.imports.internal.partial; import com.appsmith.external.constants.AnalyticsEvents; +import com.appsmith.external.helpers.Stopwatch; import com.appsmith.external.models.CreatorContextType; import com.appsmith.external.models.Datasource; import com.appsmith.server.acl.AclPermission; @@ -237,6 +238,7 @@ private Mono importResourceInPage( if (applicationJson.getPageList() == null) { return Mono.just(application); } + Stopwatch processStopwatch1 = new Stopwatch("Refactoring the widget in DSL "); // The building block is stored as a page in an application final JsonNode dsl = widgetRefactorUtil.convertDslStringToJsonNode(applicationJson .getPageList() @@ -264,6 +266,7 @@ private Mono importResourceInPage( }) .collectList() .flatMap(refactoredDsl -> { + processStopwatch1.stopAndLogTimeInMillis(); applicationJson.setWidgets(dsl.toString()); return Mono.just(application); }); @@ -414,7 +417,10 @@ public Mono importBuildingBlock(BuildingBlockDTO build Mono applicationJsonMono = applicationTemplateService.getApplicationJsonFromTemplate(buildingBlockDTO.getTemplateId()); + Stopwatch processStopwatch = new Stopwatch("Download Content from Cloud service"); return applicationJsonMono.flatMap(applicationJson -> { + processStopwatch.stopAndLogTimeInMillis(); + Stopwatch processStopwatch1 = new Stopwatch("Importing resource in db "); return this.importResourceInPage( buildingBlockDTO.getWorkspaceId(), buildingBlockDTO.getApplicationId(), @@ -422,6 +428,7 @@ public Mono importBuildingBlock(BuildingBlockDTO build branchName, applicationJson) .flatMap(buildingBlockImportDTO -> { + processStopwatch1.stopAndLogTimeInMillis(); // Fetch layout and get new onPageLoadActions // This data is not present in a client, since these are created // after importing the block diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationTemplateServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationTemplateServiceImpl.java index 43e9c7b5e5a2..4900d1d057ae 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationTemplateServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ApplicationTemplateServiceImpl.java @@ -3,6 +3,7 @@ import com.appsmith.server.applications.base.ApplicationService; import com.appsmith.server.configurations.CloudServicesConfig; import com.appsmith.server.exports.internal.ExportService; +import com.appsmith.server.helpers.CacheableTemplateHelper; import com.appsmith.server.helpers.ResponseUtils; import com.appsmith.server.imports.internal.ImportService; import com.appsmith.server.services.ce.ApplicationTemplateServiceCEImpl; @@ -28,7 +29,8 @@ public ApplicationTemplateServiceImpl( ResponseUtils responseUtils, ApplicationPermission applicationPermission, ObjectMapper objectMapper, - SessionUserService sessionUserService) { + SessionUserService sessionUserService, + CacheableTemplateHelper cacheableTemplateHelper) { super( cloudServicesConfig, releaseNotesService, @@ -40,6 +42,7 @@ public ApplicationTemplateServiceImpl( responseUtils, applicationPermission, objectMapper, - sessionUserService); + sessionUserService, + cacheableTemplateHelper); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationTemplateServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationTemplateServiceCEImpl.java index e35fa38197c3..f7ede951d999 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationTemplateServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationTemplateServiceCEImpl.java @@ -1,7 +1,6 @@ package com.appsmith.server.services.ce; import com.appsmith.external.constants.AnalyticsEvents; -import com.appsmith.external.converters.ISOStringToInstantConverter; import com.appsmith.server.applications.base.ApplicationService; import com.appsmith.server.configurations.CloudServicesConfig; import com.appsmith.server.constants.ArtifactType; @@ -13,11 +12,14 @@ import com.appsmith.server.dtos.ApplicationImportDTO; import com.appsmith.server.dtos.ApplicationJson; import com.appsmith.server.dtos.ApplicationTemplate; +import com.appsmith.server.dtos.CacheableApplicationJson; +import com.appsmith.server.dtos.CacheableApplicationTemplate; import com.appsmith.server.dtos.TemplateDTO; import com.appsmith.server.dtos.TemplateUploadDTO; import com.appsmith.server.exceptions.AppsmithError; import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.exports.internal.ExportService; +import com.appsmith.server.helpers.CacheableTemplateHelper; import com.appsmith.server.helpers.ResponseUtils; import com.appsmith.server.imports.internal.ImportService; import com.appsmith.server.services.AnalyticsService; @@ -28,17 +30,13 @@ import com.appsmith.util.WebClientUtils; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectWriter; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.reflect.TypeToken; +import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; -import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.BodyInserters; -import org.springframework.web.reactive.function.client.ExchangeStrategies; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.DefaultUriBuilderFactory; import org.springframework.web.util.UriComponents; @@ -46,12 +44,11 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.lang.reflect.Type; -import java.time.Instant; import java.util.List; import java.util.Map; @Service +@Slf4j public class ApplicationTemplateServiceCEImpl implements ApplicationTemplateServiceCE { private final CloudServicesConfig cloudServicesConfig; private final ReleaseNotesService releaseNotesService; @@ -65,6 +62,8 @@ public class ApplicationTemplateServiceCEImpl implements ApplicationTemplateServ private final ObjectMapper objectMapper; private final SessionUserService sessionUserService; + private final CacheableTemplateHelper cacheableTemplateHelper; + public ApplicationTemplateServiceCEImpl( CloudServicesConfig cloudServicesConfig, ReleaseNotesService releaseNotesService, @@ -76,7 +75,8 @@ public ApplicationTemplateServiceCEImpl( ResponseUtils responseUtils, ApplicationPermission applicationPermission, ObjectMapper objectMapper, - SessionUserService sessionUserService) { + SessionUserService sessionUserService, + CacheableTemplateHelper cacheableTemplateHelper) { this.cloudServicesConfig = cloudServicesConfig; this.releaseNotesService = releaseNotesService; this.importService = importService; @@ -88,6 +88,7 @@ public ApplicationTemplateServiceCEImpl( this.applicationPermission = applicationPermission; this.objectMapper = objectMapper; this.sessionUserService = sessionUserService; + this.cacheableTemplateHelper = cacheableTemplateHelper; } @Override @@ -114,31 +115,14 @@ public Flux getSimilarTemplates(String templateId, MultiVal @Override public Mono> getActiveTemplates(List templateIds) { - final String baseUrl = cloudServicesConfig.getBaseUrl(); - - UriComponentsBuilder uriComponentsBuilder = - UriComponentsBuilder.newInstance().queryParam("version", releaseNotesService.getRunningVersion()); - - if (!CollectionUtils.isEmpty(templateIds)) { - uriComponentsBuilder.queryParam("id", templateIds); - } - - // uriComponents will build url in format: version=version&id=id1&id=id2&id=id3 - UriComponents uriComponents = uriComponentsBuilder.build(); - - return WebClientUtils.create(baseUrl + "/api/v1/app-templates?" + uriComponents.getQuery()) - .get() - .exchangeToFlux(clientResponse -> { - if (clientResponse.statusCode().equals(HttpStatus.OK)) { - return clientResponse.bodyToFlux(ApplicationTemplate.class); - } else if (clientResponse.statusCode().isError()) { - return Flux.error( - new AppsmithException(AppsmithError.CLOUD_SERVICES_ERROR, clientResponse.statusCode())); - } else { - return clientResponse.createException().flatMapMany(Flux::error); - } - }) - .collectList(); + return cacheableTemplateHelper + .getTemplates(releaseNotesService.getRunningVersion(), cloudServicesConfig.getBaseUrl()) + .map(CacheableApplicationTemplate::getApplicationTemplateList) + .onErrorResume(e -> { + log.error("Error fetching templates data from cloud service ", e); + // If there is an error fetching the template from the cache, then evict the cache and fetch from CS + return Mono.error(new AppsmithException(AppsmithError.CLOUD_SERVICES_ERROR, e.getMessage())); + }); } @Override @@ -162,39 +146,14 @@ public Mono getTemplateDetails(String templateId) { @Override public Mono getApplicationJsonFromTemplate(String templateId) { final String baseUrl = cloudServicesConfig.getBaseUrl(); - final String templateUrl = baseUrl + "/api/v1/app-templates/" + templateId + "/application"; - /* - * using a custom url builder factory because default builder always encodes - * URL. - * It's expected that the appDataUrl is already encoded, so we don't need to - * encode that again. - * Encoding an encoded URL will not work and end up resulting a 404 error - */ - final int size = 4 * 1024 * 1024; // 4 MB - final ExchangeStrategies strategies = ExchangeStrategies.builder() - .codecs(codecs -> codecs.defaultCodecs().maxInMemorySize(size)) - .build(); - - WebClient webClient = WebClientUtils.builder() - .uriBuilderFactory(new NoEncodingUriBuilderFactory(templateUrl)) - .exchangeStrategies(strategies) - .build(); - - return webClient - .get() - .retrieve() - .bodyToMono(String.class) - .map(jsonString -> { - Gson gson = new GsonBuilder() - .registerTypeAdapter(Instant.class, new ISOStringToInstantConverter()) - .create(); - Type fileType = new TypeToken() {}.getType(); - - ApplicationJson jsonFile = gson.fromJson(jsonString, fileType); - return jsonFile; - }) - .switchIfEmpty( - Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, "template", templateId))); + return cacheableTemplateHelper + .getApplicationByTemplateId(templateId, baseUrl) + .map(CacheableApplicationJson::getApplicationJson) + .onErrorResume(e -> { + log.error("Error fetching template json data from cloud service ", e); + // If there is an error fetching the template from the cache, then evict the cache and fetch from CS + return Mono.error(new AppsmithException(AppsmithError.CLOUD_SERVICES_ERROR, templateId)); + }); } @Override diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/CacheableTemplateHelperTemplateJsonDataTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/CacheableTemplateHelperTemplateJsonDataTest.java new file mode 100644 index 000000000000..588414615342 --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/CacheableTemplateHelperTemplateJsonDataTest.java @@ -0,0 +1,191 @@ +package com.appsmith.server.helpers; + +import com.appsmith.server.configurations.CloudServicesConfig; +import com.appsmith.server.constants.ArtifactType; +import com.appsmith.server.domains.Application; +import com.appsmith.server.dtos.ApplicationJson; +import com.appsmith.server.dtos.ApplicationTemplate; +import com.appsmith.server.dtos.CacheableApplicationJson; +import com.appsmith.server.solutions.ApplicationPermission; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.Gson; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.io.IOException; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; + +/** + * This test is written based on the inspiration from the tutorial: + * https://www.baeldung.com/spring-mocking-webclient + */ +@ExtendWith(SpringExtension.class) +@SpringBootTest +public class CacheableTemplateHelperTemplateJsonDataTest { + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static MockWebServer mockCloudServices; + + @MockBean + ApplicationPermission applicationPermission; + + @MockBean + private CloudServicesConfig cloudServicesConfig; + + @Autowired + CacheableTemplateHelper cacheableTemplateHelper; + + @SpyBean + CacheableTemplateHelper spyCacheableTemplateHelper; + + @BeforeAll + public static void setUp() throws IOException { + mockCloudServices = new MockWebServer(); + mockCloudServices.start(); + } + + @AfterAll + public static void tearDown() throws IOException { + mockCloudServices.shutdown(); + } + + @BeforeEach + public void initialize() { + String baseUrl = String.format("http://localhost:%s", mockCloudServices.getPort()); + + // mock the cloud services config so that it returns mock server url as cloud + // service base url + Mockito.when(cloudServicesConfig.getBaseUrl()).thenReturn(baseUrl); + } + + private ApplicationTemplate create(String id, String title) { + ApplicationTemplate applicationTemplate = new ApplicationTemplate(); + applicationTemplate.setId(id); + applicationTemplate.setTitle(title); + return applicationTemplate; + } + + /* Scenarios covered via this test: + * 1. CacheableTemplateHelper doesn't have the POJO or has an empty POJO. + * 2. Fetch the templates via the normal flow by mocking CS. + * 3. Check if the CacheableTemplateHelper.getApplicationTemplateList() is the same as the object returned by the normal flow function. This will ensure that the cache is being set correctly. + * 4. From the above steps we now have the cache set. + * 5. Fetch the templates again, verify the data is the same as the one fetched in step 2. + * 6. Verify the cache is used and not the mock. This is done by asserting the lastUpdated time of the cache. + */ + @Test + public void getApplicationJson_cacheIsEmpty_VerifyDataSavedInCache() throws JsonProcessingException { + ApplicationJson applicationJson = new ApplicationJson(); + applicationJson.setArtifactJsonType(ArtifactType.APPLICATION); + applicationJson.setExportedApplication(new Application()); + + assertThat(cacheableTemplateHelper.getCacheableApplicationJsonMap().size()) + .isEqualTo(0); + + // mock the server to return a template when it's called + mockCloudServices.enqueue(new MockResponse() + .setBody(objectMapper.writeValueAsString(applicationJson)) + .addHeader("Content-Type", "application/json")); + + Mono templateListMono = + cacheableTemplateHelper.getApplicationByTemplateId("templateId", cloudServicesConfig.getBaseUrl()); + + final Instant[] timeFromCache = {Instant.now()}; + // make sure we've received the response returned by the mockCloudServices + StepVerifier.create(templateListMono) + .assertNext(cacheableApplicationJson1 -> { + assertThat(cacheableApplicationJson1.getApplicationJson()).isNotNull(); + timeFromCache[0] = cacheableApplicationJson1.getCacheExpiryTime(); + }) + .verifyComplete(); + + // Fetch the same application json again and verify the time stamp to confirm value is coming from POJO + StepVerifier.create(cacheableTemplateHelper.getApplicationByTemplateId( + "templateId", cloudServicesConfig.getBaseUrl())) + .assertNext(cacheableApplicationJson1 -> { + assertThat(cacheableApplicationJson1.getApplicationJson()).isNotNull(); + assertThat(cacheableApplicationJson1.getCacheExpiryTime()).isEqualTo(timeFromCache[0]); + }) + .verifyComplete(); + assertThat(cacheableTemplateHelper.getCacheableApplicationJsonMap().size()) + .isEqualTo(1); + } + + /* Scenarios covered via this test: + * 1. Mock the cache isCacheValid to return false, so the cache is invalidated + * 2. Fetch the templates again, verify the data is from the mock and not from the cache. + */ + @Test + public void getApplicationJson_cacheIsDirty_verifyDataIsFetchedFromSource() { + ApplicationJson applicationJson = new ApplicationJson(); + Application test = new Application(); + test.setName("New Application"); + applicationJson.setArtifactJsonType(ArtifactType.APPLICATION); + applicationJson.setExportedApplication(test); + + // mock the server to return the above three templates + mockCloudServices.enqueue(new MockResponse() + .setBody(new Gson().toJson(applicationJson)) + .addHeader("Content-Type", "application/json")); + + Mockito.doReturn(false).when(spyCacheableTemplateHelper).isCacheValid(any()); + + // make sure we've received the response returned by the mock + StepVerifier.create(spyCacheableTemplateHelper.getApplicationByTemplateId( + "templateId", cloudServicesConfig.getBaseUrl())) + .assertNext(cacheableApplicationJson1 -> { + assertThat(cacheableApplicationJson1.getApplicationJson()).isNotNull(); + assertThat(cacheableApplicationJson1 + .getApplicationJson() + .getExportedApplication() + .getName()) + .isEqualTo("New Application"); + }) + .verifyComplete(); + } + + @Test + public void getApplicationJson_cacheKeyIsMissing_verifyDataIsFetchedFromSource() { + ApplicationJson applicationJson1 = new ApplicationJson(); + Application application = new Application(); + application.setName("Test Application"); + applicationJson1.setArtifactJsonType(ArtifactType.APPLICATION); + applicationJson1.setExportedApplication(application); + + assertThat(cacheableTemplateHelper.getCacheableApplicationJsonMap().size()) + .isEqualTo(1); + + mockCloudServices.enqueue(new MockResponse() + .setBody(new Gson().toJson(applicationJson1)) + .addHeader("Content-Type", "application/json")); + + // make sure we've received the response returned by the mock + StepVerifier.create(cacheableTemplateHelper.getApplicationByTemplateId( + "templateId1", cloudServicesConfig.getBaseUrl())) + .assertNext(cacheableApplicationJson1 -> { + assertThat(cacheableApplicationJson1.getApplicationJson()).isNotNull(); + assertThat(cacheableApplicationJson1 + .getApplicationJson() + .getExportedApplication() + .getName()) + .isEqualTo("Test Application"); + }) + .verifyComplete(); + } +} diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/CacheableTemplateHelperTemplateMetadataTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/CacheableTemplateHelperTemplateMetadataTest.java new file mode 100644 index 000000000000..7c6e884ae9bc --- /dev/null +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/helpers/CacheableTemplateHelperTemplateMetadataTest.java @@ -0,0 +1,154 @@ +package com.appsmith.server.helpers; + +import com.appsmith.server.configurations.CloudServicesConfig; +import com.appsmith.server.dtos.ApplicationTemplate; +import com.appsmith.server.dtos.CacheableApplicationTemplate; +import com.appsmith.server.solutions.ApplicationPermission; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; + +@ExtendWith(SpringExtension.class) +@SpringBootTest +public class CacheableTemplateHelperTemplateMetadataTest { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static MockWebServer mockCloudServices; + + @MockBean + ApplicationPermission applicationPermission; + + @MockBean + private CloudServicesConfig cloudServicesConfig; + + @Autowired + CacheableTemplateHelper cacheableTemplateHelper; + + @SpyBean + CacheableTemplateHelper spyCacheableTemplateHelper; + + @BeforeAll + public static void setUp() throws IOException { + mockCloudServices = new MockWebServer(); + mockCloudServices.start(); + } + + @AfterAll + public static void tearDown() throws IOException { + mockCloudServices.shutdown(); + } + + @BeforeEach + public void initialize() { + String baseUrl = String.format("http://localhost:%s", mockCloudServices.getPort()); + + // mock the cloud services config so that it returns mock server url as cloud + // service base url + Mockito.when(cloudServicesConfig.getBaseUrl()).thenReturn(baseUrl); + } + + private ApplicationTemplate create(String id, String title) { + ApplicationTemplate applicationTemplate = new ApplicationTemplate(); + applicationTemplate.setId(id); + applicationTemplate.setTitle(title); + return applicationTemplate; + } + + /* Scenarios covered via this test: + * 1. CacheableTemplateHelper doesn't have the POJO or has an empty POJO. + * 2. Fetch the templates via the normal flow by mocking CS. + * 3. Check if the CacheableTemplateHelper.getApplicationTemplateList() is the same as the object returned by the normal flow function. This will ensure that the cache is being set correctly. + * 4. From the above steps we now have the cache set. + * 5. Fetch the templates again, verify the data is the same as the one fetched in step 2. + * 6. Verify the cache is used and not the mock. This is done by asserting the lastUpdated time of the cache. + * 7. Set the cache time to 1 day before the current time. + * 8. Fetch the templates again, verify the data is from the mock and not from the cache. + */ + @Test + public void getTemplateData_cacheIsEmpty_VerifyDataSavedInCache() throws JsonProcessingException { + ApplicationTemplate templateOne = create("id-one", "First template"); + ApplicationTemplate templateTwo = create("id-two", "Seconds template"); + ApplicationTemplate templateThree = create("id-three", "Third template"); + + // mock the server to return the above three templates + mockCloudServices.enqueue(new MockResponse() + .setBody(objectMapper.writeValueAsString(List.of(templateOne, templateTwo, templateThree))) + .addHeader("Content-Type", "application/json")); + + Mono templateListMono = + cacheableTemplateHelper.getTemplates("recently-used", cloudServicesConfig.getBaseUrl()); + + final Instant[] timeFromCache = {Instant.now()}; + StepVerifier.create(templateListMono) + .assertNext(cacheableApplicationTemplate1 -> { + assertThat(cacheableApplicationTemplate1.getApplicationTemplateList()) + .hasSize(3); + cacheableApplicationTemplate1.getApplicationTemplateList().forEach(applicationTemplate -> { + assertThat(applicationTemplate.getId()).isIn("id-one", "id-two", "id-three"); + }); + timeFromCache[0] = cacheableApplicationTemplate1.getCacheExpiryTime(); + }) + .verifyComplete(); + + // Fetch again and verify the time stamp to confirm value is coming from POJO + StepVerifier.create(cacheableTemplateHelper.getTemplates("recently-used", cloudServicesConfig.getBaseUrl())) + .assertNext(cacheableApplicationTemplate1 -> { + assertThat(cacheableApplicationTemplate1.getApplicationTemplateList()) + .hasSize(3); + cacheableApplicationTemplate1.getApplicationTemplateList().forEach(applicationTemplate -> { + assertThat(applicationTemplate.getId()).isIn("id-one", "id-two", "id-three"); + }); + assertThat(cacheableApplicationTemplate1.getCacheExpiryTime()) + .isEqualTo(timeFromCache[0]); + }) + .verifyComplete(); + + /* Scenarios covered via this test: + * 1. Mock the cache isCacheValid to return false, so the cache is invalidated + * 2. Fetch the templates again, verify the data is from the mock and not from the cache. + */ + ApplicationTemplate templateFour = create("id-four", "Fourth template"); + ApplicationTemplate templateFive = create("id-five", "Fifth template"); + ApplicationTemplate templateSix = create("id-six", "Sixth template"); + + Mockito.doReturn(false).when(spyCacheableTemplateHelper).isCacheValid(any()); + + // mock the server to return the above three templates + mockCloudServices.enqueue(new MockResponse() + .setBody(objectMapper.writeValueAsString(List.of(templateFour, templateFive, templateSix))) + .addHeader("Content-Type", "application/json")); + + StepVerifier.create(spyCacheableTemplateHelper.getTemplates("recently-used", cloudServicesConfig.getBaseUrl())) + .assertNext(cacheableApplicationTemplate1 -> { + assertThat(cacheableApplicationTemplate1.getApplicationTemplateList()) + .hasSize(3); + cacheableApplicationTemplate1.getApplicationTemplateList().forEach(applicationTemplate -> { + assertThat(applicationTemplate.getId()).isIn("id-four", "id-five", "id-six"); + assertThat(applicationTemplate.getTitle()) + .isIn("Fourth template", "Fifth template", "Sixth template"); + }); + }) + .verifyComplete(); + } +} diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ApplicationTemplateServiceUnitTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ApplicationTemplateServiceUnitTest.java deleted file mode 100644 index 7bccc37c4f51..000000000000 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/services/ApplicationTemplateServiceUnitTest.java +++ /dev/null @@ -1,169 +0,0 @@ -package com.appsmith.server.services; - -import com.appsmith.server.applications.base.ApplicationService; -import com.appsmith.server.configurations.CloudServicesConfig; -import com.appsmith.server.dtos.ApplicationTemplate; -import com.appsmith.server.dtos.PageNameIdDTO; -import com.appsmith.server.exports.internal.ExportService; -import com.appsmith.server.helpers.ResponseUtils; -import com.appsmith.server.imports.internal.ImportService; -import com.appsmith.server.solutions.ApplicationPermission; -import com.appsmith.server.solutions.ReleaseNotesService; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import mockwebserver3.MockResponse; -import mockwebserver3.MockWebServer; -import org.json.JSONArray; -import org.json.JSONObject; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mockito; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; - -import java.io.IOException; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * This test is written based on the inspiration from the tutorial: - * https://www.baeldung.com/spring-mocking-webclient - */ -@ExtendWith(SpringExtension.class) -public class ApplicationTemplateServiceUnitTest { - private static final ObjectMapper objectMapper = new ObjectMapper(); - private static MockWebServer mockCloudServices; - ApplicationTemplateService applicationTemplateService; - - @MockBean - ApplicationPermission applicationPermission; - - @MockBean - private UserDataService userDataService; - - @MockBean - private CloudServicesConfig cloudServicesConfig; - - @MockBean - private ReleaseNotesService releaseNotesService; - - @MockBean - private ImportService importService; - - @MockBean - private ExportService exportService; - - @MockBean - private AnalyticsService analyticsService; - - @MockBean - private ApplicationService applicationService; - - @MockBean - private ResponseUtils responseUtils; - - @MockBean - private SessionUserService sessionUserService; - - @BeforeAll - public static void setUp() throws IOException { - mockCloudServices = new MockWebServer(); - mockCloudServices.start(); - } - - @AfterAll - public static void tearDown() throws IOException { - mockCloudServices.shutdown(); - } - - @BeforeEach - public void initialize() { - String baseUrl = String.format("http://localhost:%s", mockCloudServices.getPort()); - - // mock the cloud services config so that it returns mock server url as cloud - // service base url - Mockito.when(cloudServicesConfig.getBaseUrl()).thenReturn(baseUrl); - - applicationTemplateService = new ApplicationTemplateServiceImpl( - cloudServicesConfig, - releaseNotesService, - importService, - exportService, - analyticsService, - userDataService, - applicationService, - responseUtils, - applicationPermission, - objectMapper, - sessionUserService); - } - - private ApplicationTemplate create(String id, String title) { - ApplicationTemplate applicationTemplate = new ApplicationTemplate(); - applicationTemplate.setId(id); - applicationTemplate.setTitle(title); - return applicationTemplate; - } - - @Test - public void getActiveTemplates_WhenRecentlyUsedExists_RecentOnesComesFirst() throws JsonProcessingException { - ApplicationTemplate templateOne = create("id-one", "First template"); - ApplicationTemplate templateTwo = create("id-two", "Seonds template"); - ApplicationTemplate templateThree = create("id-three", "Third template"); - - // mock the server to return the above three templates - mockCloudServices.enqueue(new MockResponse() - .setBody(objectMapper.writeValueAsString(List.of(templateOne, templateTwo, templateThree))) - .addHeader("Content-Type", "application/json")); - - Mono> templateListMono = applicationTemplateService.getActiveTemplates(null); - - StepVerifier.create(templateListMono) - .assertNext(applicationTemplates -> { - assertThat(applicationTemplates).hasSize(3); - }) - .verifyComplete(); - } - - @Test - public void get_WhenPageMetaDataExists_PageMetaDataParsedProperly() throws JsonProcessingException { - JSONObject jsonObject = new JSONObject(); - jsonObject.put("id", "1234567890"); - jsonObject.put("name", "My Page"); - jsonObject.put("icon", "flight"); - jsonObject.put("isDefault", true); - JSONArray pages = new JSONArray(); - pages.put(jsonObject); - - JSONObject templateObj = new JSONObject(); - templateObj.put("title", "My Template"); - templateObj.put("pages", pages); - - JSONArray templates = new JSONArray(); - templates.put(templateObj); - - // mock the server to return a template when it's called - mockCloudServices.enqueue( - new MockResponse().setBody(templates.toString()).addHeader("Content-Type", "application/json")); - - // make sure we've received the response returned by the mockCloudServices - StepVerifier.create(applicationTemplateService.getActiveTemplates(null)) - .assertNext(applicationTemplates -> { - assertThat(applicationTemplates).hasSize(1); - ApplicationTemplate applicationTemplate = applicationTemplates.get(0); - assertThat(applicationTemplate.getPages()).hasSize(1); - PageNameIdDTO pageNameIdDTO = applicationTemplate.getPages().get(0); - assertThat(pageNameIdDTO.getId()).isEqualTo("1234567890"); - assertThat(pageNameIdDTO.getName()).isEqualTo("My Page"); - assertThat(pageNameIdDTO.getIcon()).isEqualTo("flight"); - assertThat(pageNameIdDTO.getIsDefault()).isTrue(); - }) - .verifyComplete(); - } -}