From 491772da657017413b36e34d76468d0fff44e2fe Mon Sep 17 00:00:00 2001 From: Roman Barannyk <53909129+roman-barannyk@users.noreply.github.com> Date: Mon, 3 Jul 2023 17:02:17 +0300 Subject: [PATCH] [CIRC-1845] Release v23.5.5 (#1302) * working version of new timed rules cache (cherry picked from commit 38841b62a8325ed68ca9fc3d8c4b3999d81665f3) * working changes for check in and check out (cherry picked from commit 941de6ec3f36f30633912cd6ec811425238bef98) * tests passing (cherry picked from commit 6da9625766c91e905f8cf64396d516f9f9d5b255) * code cleanup (cherry picked from commit 92abe6383d97371dda50f2ca5e246efae3f4a71b) * making rules object thread-safe (cherry picked from commit 6fd42758ce5f185b1b7c3080fc2b46223ad738f5) * now clearing only tenant cache (cherry picked from commit e8897dd227382c2d543862e86353d9affacee480) * removing source of code smell (cherry picked from commit 7773c95e6ebd4cc462cab220b326fddd41e50f9d) * moving reload to stand-alone endpoint (cherry picked from commit 7c54fd304fb61a74fc77b6b11e9978440d92517e) * working test of new endpoint (cherry picked from commit 24b7f9ffe886d14cb0467396db2a440e61043adf) * code cleanup (cherry picked from commit 17d598ff451a9a98afd1532df90e368921f1f05b) * incorporating review comments (cherry picked from commit 9722702c4c17096fd5969d9e23595bf31480647e) * Reload rules when new rules are submitted (cherry picked from commit 83d9f8d9a87c49903723e4020bbcf40f19227986) * Extract method for getting rules as text CIRC-1783 (cherry picked from commit 58ef216dead5b90b61e2119d1808f8ab545c6afa) * Reduce duplication when reloading rules CIRC-1783 (cherry picked from commit 69e90016daedc79d13f4e6ff87ad45f348043a26) * incorporating review comments (cherry picked from commit 0f43b8c7a71b06b177638be2ea599d962fd7fe1b) * review feedback (cherry picked from commit 423d9d4bfbf6eec459aa0529345608da5909da3d) * adding logging to timer endpoint (cherry picked from commit 2639b2161c64a7f300ddcbdac035583d5730bfe3) * changing log level to debug (cherry picked from commit 9958e92667fcb956a63acb7038a83b6674248e65) * removing completeablefuture (cherry picked from commit f3203c3e975a59c05e71315384490df712b7ff4d) * fixing code smells * Update NEWS * [maven-release-plugin] prepare release v23.5.5 * [maven-release-plugin] prepare for next development iteration --------- Co-authored-by: felkerk Co-authored-by: Marc Johnson --- NEWS.md | 4 + descriptors/ModuleDescriptor-template.json | 11 ++ pom.xml | 2 +- .../circulation/CirculationVerticle.java | 3 + .../CirculationRulesReloadResource.java | 61 ++++++ .../resources/CirculationRulesResource.java | 13 +- .../rules/cache/CirculationRulesCache.java | 176 +++++------------- .../folio/circulation/rules/cache/Rules.java | 23 +++ .../api/CirculationRulesEngineAPITests.java | 17 +- .../fixtures/CirculationRulesFixture.java | 9 +- .../java/api/support/http/InterfaceUrls.java | 4 + 11 files changed, 170 insertions(+), 153 deletions(-) create mode 100644 src/main/java/org/folio/circulation/resources/CirculationRulesReloadResource.java create mode 100644 src/main/java/org/folio/circulation/rules/cache/Rules.java diff --git a/NEWS.md b/NEWS.md index 35fe273c8d..17b3fd27b1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,7 @@ +## 23.5.5 2023-07-03 + +* Circulation rules decoupling and timer-based refresh enhancement (CIRC-1783) + ## 23.5.4 2023-03-29 * Only notify patron for recalls that change due date (CIRC-1747) diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 88a669c30f..5b3153ca6c 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -616,6 +616,17 @@ "version": "1.0", "interfaceType": "system", "handlers": [ + { + "methods": [ + "POST" + ], + "pathPattern": "/circulation/rules-reload", + "modulePermissions": [ + "circulation.rules.get" + ], + "unit": "minute", + "delay": "3" + }, { "methods": [ "POST" diff --git a/pom.xml b/pom.xml index f1fcfb5fe8..0ba7f5350d 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 mod-circulation org.folio - 23.5.5-SNAPSHOT + 23.5.6-SNAPSHOT Apache License 2.0 diff --git a/src/main/java/org/folio/circulation/CirculationVerticle.java b/src/main/java/org/folio/circulation/CirculationVerticle.java index de26f99ef9..491d40887c 100644 --- a/src/main/java/org/folio/circulation/CirculationVerticle.java +++ b/src/main/java/org/folio/circulation/CirculationVerticle.java @@ -7,6 +7,7 @@ import org.folio.circulation.resources.ChangeDueDateResource; import org.folio.circulation.resources.CheckInByBarcodeResource; import org.folio.circulation.resources.CheckOutByBarcodeResource; +import org.folio.circulation.resources.CirculationRulesReloadResource; import org.folio.circulation.resources.CirculationRulesResource; import org.folio.circulation.resources.ClaimItemReturnedResource; import org.folio.circulation.resources.DeclareClaimedReturnedItemAsMissingResource; @@ -93,6 +94,8 @@ public void start(Promise startFuture) { new CirculationRulesResource("/circulation/rules", client) .register(router); + new CirculationRulesReloadResource("/circulation/rules-reload", client) + .register(router); new LoanCirculationRulesEngineResource( "/circulation/rules/loan-policy", "/circulation/rules/loan-policy-all", client) diff --git a/src/main/java/org/folio/circulation/resources/CirculationRulesReloadResource.java b/src/main/java/org/folio/circulation/resources/CirculationRulesReloadResource.java new file mode 100644 index 0000000000..9c462ec2cf --- /dev/null +++ b/src/main/java/org/folio/circulation/resources/CirculationRulesReloadResource.java @@ -0,0 +1,61 @@ +package org.folio.circulation.resources; + +import static org.folio.circulation.support.results.MappingFunctions.toFixedValue; + +import java.lang.invoke.MethodHandles; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.circulation.rules.cache.CirculationRulesCache; +import org.folio.circulation.support.Clients; +import org.folio.circulation.support.http.server.NoContentResponse; +import org.folio.circulation.support.http.server.WebContext; +import org.folio.circulation.support.results.CommonFailures; + +import io.vertx.core.http.HttpClient; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; + +/** + * Write and read the circulation rules. + */ +public class CirculationRulesReloadResource extends Resource { + private final Logger log = LogManager.getLogger(MethodHandles.lookup().lookupClass()); + private final String rootPath; + + /** + * Set the URL path. + * @param rootPath URL path + * @param client HTTP client + */ + public CirculationRulesReloadResource(String rootPath, HttpClient client) { + super(client); + this.rootPath = rootPath; + } + + /** + * Register the path set in the constructor. + * @param router where to register + */ + @Override + public void register(Router router) { + router.post(rootPath).handler(this::reload); + } + + private void reload(RoutingContext routingContext) { + log.debug("reload:: starting reload of circulation rules"); + final WebContext context = new WebContext(routingContext); + CirculationRulesCache.getInstance().reloadRules(context.getTenantId(), + Clients.create(context, client).circulationRulesStorage()) + .thenApply(r -> { + if(r.failed()) { + log.debug("reload:: reload failed: {}", r.cause()); + } else { + log.debug("reload:: reload succeeded."); + } + return r.map(toFixedValue(NoContentResponse::noContent)); + }) + .exceptionally(CommonFailures::failedDueToServerError) + .thenAccept(context::writeResultToHttpResponse); + } +} diff --git a/src/main/java/org/folio/circulation/resources/CirculationRulesResource.java b/src/main/java/org/folio/circulation/resources/CirculationRulesResource.java index 25ea097b25..baf371562e 100644 --- a/src/main/java/org/folio/circulation/resources/CirculationRulesResource.java +++ b/src/main/java/org/folio/circulation/resources/CirculationRulesResource.java @@ -3,6 +3,7 @@ import static java.util.stream.Collectors.groupingBy; import static java.util.stream.Collectors.mapping; import static java.util.stream.Collectors.toSet; + import static org.apache.commons.collections4.CollectionUtils.isNotEmpty; import static org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace; import static org.folio.circulation.support.http.server.JsonHttpResponse.ok; @@ -130,10 +131,12 @@ private void proceedWithUpdate(Map> existingPoliciesIds, final WebContext webContext = new WebContext(routingContext); JsonObject rulesInput; + String rulesAsText; try { // try to convert, do not safe if conversion fails rulesInput = routingContext.getBodyAsJson(); - Text2Drools.convert(rulesInput.getString("rulesAsText"), + rulesAsText = getRulesAsText(rulesInput); + Text2Drools.convert(rulesAsText, (policyType, policies, token) -> validatePolicy( existingPoliciesIds, policyType, policies, token)); } catch (CirculationRulesException e) { @@ -149,10 +152,10 @@ private void proceedWithUpdate(Map> existingPoliciesIds, clients.circulationRulesStorage().put(rulesInput.copy()) .thenApply(this::failWhenResponseOtherThanNoContent) + .thenApply(result -> result.map(response -> CirculationRulesCache.getInstance() + .reloadRules(webContext.getTenantId(), rulesAsText))) .thenApply(result -> result.map(response -> noContent())) .thenAccept(webContext::writeResultToHttpResponse); - - CirculationRulesCache.getInstance().clearCache(webContext.getTenantId()); } private Result failWhenResponseOtherThanNoContent(Result result) { @@ -161,6 +164,10 @@ private Result failWhenResponseOtherThanNoContent(Result res ForwardOnFailure::new); } + private static String getRulesAsText(JsonObject rulesInput) { + return rulesInput.getString("rulesAsText"); + } + private void validatePolicy(Map> existingPoliciesIds, String policyType, List policies, Token token) { diff --git a/src/main/java/org/folio/circulation/rules/cache/CirculationRulesCache.java b/src/main/java/org/folio/circulation/rules/cache/CirculationRulesCache.java index e245a485f0..2188d0f7d6 100644 --- a/src/main/java/org/folio/circulation/rules/cache/CirculationRulesCache.java +++ b/src/main/java/org/folio/circulation/rules/cache/CirculationRulesCache.java @@ -1,41 +1,35 @@ package org.folio.circulation.rules.cache; -import static java.util.concurrent.CompletableFuture.completedFuture; import static org.apache.commons.lang3.StringUtils.isBlank; import static org.folio.circulation.support.results.Result.failed; import static org.folio.circulation.support.results.Result.ofAsync; import static org.folio.circulation.support.results.Result.succeeded; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import java.lang.invoke.MethodHandles; +import java.text.DateFormat; +import java.text.SimpleDateFormat; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.folio.circulation.rules.Drools; import org.folio.circulation.rules.ExecutableRules; import org.folio.circulation.rules.Text2Drools; import org.folio.circulation.support.CollectionResourceClient; import org.folio.circulation.support.ServerErrorFailure; +import org.folio.circulation.support.http.client.Response; import org.folio.circulation.support.results.Result; - import io.vertx.core.json.JsonObject; public final class CirculationRulesCache { private static final Logger log = LogManager.getLogger(MethodHandles.lookup().lookupClass()); private static final CirculationRulesCache instance = new CirculationRulesCache(); - /** after this time the rules get loaded before executing the circulation rules engine */ - private static final long MAX_AGE_IN_MILLISECONDS = 5000; - /** after this time the circulation rules engine is executed first for a fast reply - * and then the circulation rules get reloaded */ - private static final long TRIGGER_AGE_IN_MILLISECONDS = 4000; - /** after this time the Drools object will be rebuilt even if rulesAsText has not changed */ - private static final long DROOLS_OBJECT_LIFETIME_IN_MILLISECONDS = 30000; /** rules and Drools for each tenantId */ - private final Map rulesMap = new ConcurrentHashMap<>(); + private Map rulesMap = new ConcurrentHashMap<>(); public static CirculationRulesCache getInstance() { return instance; @@ -43,113 +37,54 @@ public static CirculationRulesCache getInstance() { private CirculationRulesCache() {} - /** - * Completely drop the cache. This enforces rebuilding the drools rules - * even when the circulation rules haven't changed. - */ public void dropCache() { rulesMap.clear(); } - /** - * Enforce reload of the tenant's circulation rules. - * This doesn't rebuild the drools rules if the circulation rules haven't changed. - * @param tenantId id of the tenant - */ - public void clearCache(String tenantId) { + private boolean rulesExist(String tenantId) { Rules rules = rulesMap.get(tenantId); - if (rules == null) { - return; - } - rules.reloadTimestamp = 0; - } - - private boolean isCurrent(String tenantId, Rules rules) { - if (rules == null) { - log.info("Rules object is null considering it not current for tenant {}", tenantId); - return false; - } - - long currentTimestamp = System.currentTimeMillis(); - boolean isCurrent = rules.reloadTimestamp + MAX_AGE_IN_MILLISECONDS > currentTimestamp; - log.info("Rules object is current for tenant {}: {}. " + - "Reload timestamp is {}, current timestamp is {}", - tenantId, isCurrent, rules.reloadTimestamp, currentTimestamp); - return isCurrent; - } - - /** - * Reload is needed if the last reload is TRIGGER_AGE_IN_MILLISECONDS old - * and a reload hasn't been initiated yet. - * @param rules - rules to reload - * @return whether reload is needed - */ - private boolean reloadNeeded(String tenantId, Rules rules) { - if (rules.reloadInitiated) { - log.info("Rules reload is already initiated for tenant {}", tenantId); - return false; - } - - long currentTimestamp = System.currentTimeMillis(); - boolean reloadNeeded = rules.reloadTimestamp + TRIGGER_AGE_IN_MILLISECONDS < currentTimestamp; - log.info("Rules reload is needed for tenant {}: {}. " + - "Reload timestamp is {}, current timestamp is {}", - tenantId, reloadNeeded, rules.reloadTimestamp, currentTimestamp); - return reloadNeeded; - } - private boolean rebuildNeeded(String tenantId, Rules rules) { - long currentTimestamp = System.currentTimeMillis(); - boolean rebuildNeeded = rules.rebuildTimestamp + DROOLS_OBJECT_LIFETIME_IN_MILLISECONDS < - currentTimestamp; - log.info("Drools object rebuild is needed for tenant {}: {}. " + - "Rebuild timestamp is {}, current timestamp is {}", - tenantId, rebuildNeeded, rules.rebuildTimestamp, currentTimestamp); - return rebuildNeeded; + return rules != null; } - private CompletableFuture> reloadRules(String tenantId, Rules rules, + public CompletableFuture> reloadRules(String tenantId, CollectionResourceClient circulationRulesClient) { - log.info("Reloading rules for tenant {}", tenantId); return circulationRulesClient.get() - .thenCompose(r -> r.after(response -> { - log.info("Fetched rules for tenant {}", tenantId); - JsonObject circulationRules = new JsonObject(response.getBody()); + .thenApply(r -> r.map(response -> getRulesAsText(response, tenantId))) + .thenApply(r -> r.next(rulesAsText -> reloadRules(tenantId, rulesAsText))); + } - rules.reloadTimestamp = System.currentTimeMillis(); - rules.reloadInitiated = false; + private static String getRulesAsText(Response response, String tenantId) { + log.info("Fetched rules for tenant {}", tenantId); + + final var circulationRules = new JsonObject(response.getBody()); + var encodeRules = circulationRules.encodePrettily(); + log.info("circulationRules = {}", encodeRules); - if (log.isInfoEnabled()) { - log.info("circulationRules = {}", circulationRules.encodePrettily()); - } + return circulationRules.getString("rulesAsText"); + } - String rulesAsText = circulationRules.getString("rulesAsText"); + public Result reloadRules(String tenantId, String rulesAsText) { + log.debug("reloadRules:: parameters tenantId: {}, rulesAsText: {}", tenantId, rulesAsText); if (isBlank(rulesAsText)) { log.info("Rules text is blank for tenant {}", tenantId); - return completedFuture(failed(new ServerErrorFailure( - "Cannot apply blank circulation rules"))); - } - - if (rules.rulesAsText.equals(rulesAsText) && !rebuildNeeded(tenantId, rules)) { - log.info("Rules have not changed for tenant {} and rebuild is not needed", - tenantId); - return ofAsync(() -> rules); + return failed(new ServerErrorFailure( + "Cannot apply blank circulation rules")); } - rules.rulesAsText = rulesAsText; - - rules.rulesAsDrools = Text2Drools.convert(rulesAsText); - log.info("rulesAsDrools = {}", rules.rulesAsDrools); + String droolsText = Text2Drools.convert(rulesAsText); + Drools drools = new Drools(tenantId, droolsText); + log.info("rulesAsDrools = {}", droolsText); - rules.drools = new Drools(tenantId, rules.rulesAsDrools); - rules.rebuildTimestamp = System.currentTimeMillis(); + Rules rules = new Rules(rulesAsText, droolsText, drools, System.currentTimeMillis()); log.info("Done building Drools object for tenant {}", tenantId); - return ofAsync(() -> rules); - })); + rulesMap.put(tenantId, rules); + + return succeeded(rules); } public CompletableFuture> getExecutableRules(String tenantId, @@ -157,52 +92,25 @@ public CompletableFuture> getExecutableRules(String tena return getDrools(tenantId, circulationRulesClient) .thenApply(r -> r.map(drools -> - new ExecutableRules(rulesMap.get(tenantId).rulesAsText, drools))); + new ExecutableRules(rulesMap.get(tenantId).getRulesAsText(), drools))); } public CompletableFuture> getDrools(String tenantId, CollectionResourceClient circulationRulesClient) { - log.info("Getting Drools for tenant {}", tenantId); - - final CompletableFuture> cfDrools = new CompletableFuture<>(); - Rules rules = rulesMap.get(tenantId); - - if (isCurrent(tenantId, rules)) { - log.info("Rules for tenant {} are current, returning immediately: {}", tenantId, - rules.rulesAsText); - - cfDrools.complete(succeeded(rules.drools)); - - if (reloadNeeded(tenantId, rules)) { - log.info("Need to reload rules for tenant {}", tenantId); + log.debug("getDrools:: parameters: tenantId, circulationRulesClient: Getting Drools for tenant {}", tenantId); - rules.reloadInitiated = true; - reloadRules(tenantId, rules, circulationRulesClient) - .thenCompose(r -> r.after(updatedRules -> ofAsync(() -> updatedRules.drools))); - } - - return cfDrools; - } - - if (rules == null) { - log.info("Rules are null for tenant {}, initializing", tenantId); - rules = new Rules(); - rulesMap.put(tenantId, rules); + if (tenantId != null && rulesExist(tenantId)) { + Rules rules = rulesMap.get(tenantId); + DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"); + String strDate = dateFormat.format(rules.getReloadTimestamp()); + log.info("Rules object found, last updated: {}", strDate); + return ofAsync(rules.getDrools()); } - return reloadRules(tenantId, rules, circulationRulesClient) - .thenCompose(r -> r.after(updatedRules -> ofAsync(() -> updatedRules.drools))); - } + log.info("Circulation rules have not been loaded, initializing"); - private class Rules { - private volatile String rulesAsText = ""; - private volatile String rulesAsDrools = ""; - private volatile Drools drools; - /** System.currentTimeMillis() of the last load/reload of the rules from the storage */ - private volatile long reloadTimestamp; - /** System.currentTimeMillis() of the last rebuild of the Drools object */ - private volatile long rebuildTimestamp; - private volatile boolean reloadInitiated = false; + return reloadRules(tenantId, circulationRulesClient) + .thenApply(r -> r.map(Rules::getDrools)); } } diff --git a/src/main/java/org/folio/circulation/rules/cache/Rules.java b/src/main/java/org/folio/circulation/rules/cache/Rules.java new file mode 100644 index 0000000000..f268016c11 --- /dev/null +++ b/src/main/java/org/folio/circulation/rules/cache/Rules.java @@ -0,0 +1,23 @@ +package org.folio.circulation.rules.cache; + +import org.folio.circulation.rules.Drools; + +import lombok.Getter; +import lombok.AllArgsConstructor; + +@Getter +@AllArgsConstructor +public class Rules { + private final String rulesAsText; + private final String rulesAsDrools; + private final Drools drools; + /** System.currentTimeMillis() of the last load/reload of the rules from the storage */ + private final long reloadTimestamp; + + public Rules() { + rulesAsText = ""; + rulesAsDrools = ""; + drools = null; + reloadTimestamp = 0; + } +} diff --git a/src/test/java/api/CirculationRulesEngineAPITests.java b/src/test/java/api/CirculationRulesEngineAPITests.java index 2c90fe2f9f..39be53474f 100644 --- a/src/test/java/api/CirculationRulesEngineAPITests.java +++ b/src/test/java/api/CirculationRulesEngineAPITests.java @@ -387,20 +387,9 @@ void cachedRulesAreUsedEvenWhenRulesInStorageHaveBeenChanged() { } @Test - void cacheIsInvalidatedAfterFiveSeconds() { - setRules(rulesFallback); - assertThat(applyRulesForLoanPolicy(m1, t1, g1, s1), is(lp6)); - - circulationRulesFixture.updateCirculationRulesWithoutInvalidatingCache( - rulesFallback2); - - // Poll until the cached rules should have been replaced - await() - .atLeast(4, SECONDS) - .atMost(6, SECONDS) - .pollDelay(1, SECONDS) - .pollInterval(1, SECONDS) - .until(() -> applyRulesForLoanPolicy(m1, t1, g1, s1), is(lp7)); + void canRefreshRules() { + Response response = circulationRulesFixture.attemptRefreshRules(); + assertThat(response.getStatusCode(), is(204)); } private Policy applyRulesForLoanPolicy(ItemType itemType, LoanType loanType, diff --git a/src/test/java/api/support/fixtures/CirculationRulesFixture.java b/src/test/java/api/support/fixtures/CirculationRulesFixture.java index 44cf493277..60db909db4 100644 --- a/src/test/java/api/support/fixtures/CirculationRulesFixture.java +++ b/src/test/java/api/support/fixtures/CirculationRulesFixture.java @@ -3,6 +3,7 @@ import static api.support.RestAssuredResponseConversion.toResponse; import static api.support.http.InterfaceUrls.circulationRulesStorageUrl; import static api.support.http.InterfaceUrls.circulationRulesUrl; +import static api.support.http.InterfaceUrls.circulationRulesReloadUrl; import static api.support.http.api.support.NamedQueryStringParameter.namedParameter; import static java.util.Arrays.asList; import static org.hamcrest.CoreMatchers.not; @@ -44,7 +45,6 @@ public String getCirculationRules() { return rulesJson.getString("rulesAsText"); } - public Response putRules(String body) { return toResponse(restAssuredClient .beginRequest("put-circulation-rules") @@ -80,6 +80,13 @@ public Response attemptUpdateCirculationRules(String rules) { return putRules(circulationRulesRequest.encodePrettily()); } + public Response attemptRefreshRules() { + return toResponse(restAssuredClient + .beginRequest("refresh-rules-in-cache") + .when().post(circulationRulesReloadUrl("")) + .then().extract().response()); + } + public void updateCirculationRulesWithoutInvalidatingCache(String rules) { JsonObject json = new JsonObject().put("rulesAsText", rules); diff --git a/src/test/java/api/support/http/InterfaceUrls.java b/src/test/java/api/support/http/InterfaceUrls.java index 793ee2d787..2fb7f6eebd 100644 --- a/src/test/java/api/support/http/InterfaceUrls.java +++ b/src/test/java/api/support/http/InterfaceUrls.java @@ -206,6 +206,10 @@ public static URL circulationRulesUrl() { return circulationRulesUrl(""); } + public static URL circulationRulesReloadUrl(String subPath) { + return circulationModuleUrl("/circulation/rules-reload" + subPath); + } + public static URL circulationRulesUrl(String subPath) { return circulationModuleUrl("/circulation/rules" + subPath); }