diff --git a/dspace-api/src/main/java/org/dspace/content/authority/SimpleRORAuthority.java b/dspace-api/src/main/java/org/dspace/content/authority/SimpleRORAuthority.java
new file mode 100644
index 000000000000..80425bd977c4
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/content/authority/SimpleRORAuthority.java
@@ -0,0 +1,119 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+package org.dspace.content.authority;
+
+import org.dspace.external.RorRestConnector;
+import org.dspace.utils.DSpace;
+
+/**
+ * ChoiceAuthority using the ROR API.
+ *
+ * @author Milan Kuchtiak
+ */
+public class SimpleRORAuthority implements ChoiceAuthority {
+
+ private String pluginInstanceName;
+
+ private final RorRestConnector rorRestConnector = new DSpace().getServiceManager().getServiceByName(
+ "RorRestConnector", RorRestConnector.class);
+
+ /**
+ * Get all values from the authority that match the preferred value.
+ * Note that the offering was entered by the user and may contain
+ * mixed/incorrect case, whitespace, etc so the plugin should be careful
+ * to clean up user data before making comparisons.
+ *
+ * Value of a "Name" field will be in canonical DSpace person name format,
+ * which is "Lastname, Firstname(s)", e.g. "Smith, John Q.".
+ *
+ * Some authorities with a small set of values may simply return the whole
+ * set for any sample value, although it's a good idea to set the
+ * defaultSelected index in the Choices instance to the choice, if any,
+ * that matches the value.
+ *
+ * @param text user's value to match
+ * @param start choice at which to start, 0 is first.
+ * @param limit maximum number of choices to return, 0 for no limit.
+ * @param locale explicit localization key if available, or null
+ * @return a Choices object (never null).
+ */
+ @Override
+ public Choices getMatches(String text, int start, int limit, String locale) {
+ return rorRestConnector.getMatches(text, start, limit, locale);
+ }
+
+ /**
+ * Get the single "best" match (if any) of a value in the authority
+ * to the given user value. The "confidence" element of Choices is
+ * expected to be set to a meaningful value about the circumstances of
+ * this match.
+ *
+ * This call is typically used in non-interactive metadata ingest
+ * where there is no interactive agent to choose from among options.
+ *
+ * @param text user's value to match
+ * @param locale explicit localization key if available, or null
+ * @return a Choices object (never null) with 1 or 0 values.
+ */
+ @Override
+ public Choices getBestMatch(String text, String locale) {
+ return rorRestConnector.getBestMatch(text, locale);
+ }
+
+ @Override
+ public Choice getChoice(String authKey, String locale) {
+ return rorRestConnector.getChoice(authKey, locale);
+ }
+
+ /**
+ * Get the canonical user-visible "label" (i.e. short descriptive text)
+ * for a key in the authority. Can be localized given the implicit
+ * or explicit locale specification.
+ *
+ * This may get called many times while populating a Web page so it should
+ * be implemented as efficiently as possible.
+ *
+ * @param key authority key known to this authority.
+ * @param locale explicit localization key if available, or null
+ * @return descriptive label - should always return something, never null.
+ */
+ @Override
+ public String getLabel(String key, String locale) {
+ return rorRestConnector.getLabel(key, locale);
+ }
+
+ /**
+ * Get the instance's particular name.
+ * Returns the name by which the class was chosen when
+ * this instance was created. Only works for instances created
+ * by PluginService, or if someone remembers to call setPluginName.
+ *
+ * Useful when the implementation class wants to be configured differently
+ * when it is invoked under different names.
+ *
+ * @return name or null if not available.
+ */
+ @Override
+ public String getPluginInstanceName() {
+ return pluginInstanceName;
+ }
+
+ /**
+ * Set the name under which this plugin was instantiated.
+ * Not to be invoked by application code, it is
+ * called automatically by PluginService.getNamedPlugin()
+ * when the plugin is instantiated.
+ *
+ * @param name -- name used to select this class.
+ */
+ @Override
+ public void setPluginInstanceName(String name) {
+ this.pluginInstanceName = name;
+ }
+
+}
diff --git a/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java b/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java
new file mode 100644
index 000000000000..fb36dfa08349
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java
@@ -0,0 +1,350 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+package org.dspace.external;
+
+import java.io.InputStream;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import javax.ws.rs.client.Client;
+import javax.ws.rs.client.ClientBuilder;
+import javax.ws.rs.core.Response;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.commons.lang3.LocaleUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.dspace.content.authority.Choice;
+import org.dspace.content.authority.Choices;
+import org.dspace.external.model.ror.Location;
+import org.dspace.external.model.ror.RorItem;
+import org.dspace.external.model.ror.RorItems;
+import org.dspace.services.ConfigurationService;
+import org.dspace.services.factory.DSpaceServicesFactory;
+import org.springframework.cache.annotation.Cacheable;
+
+/**
+ * REST connector for ROR API. It is used by RORAuthority to retrieve data from ROR API.
+ *
+ * @author Milan Kuchtiak
+ */
+public class RorRestConnector {
+
+ private static final Logger log = LogManager.getLogger(RorRestConnector.class);
+
+ static final String ROR_ID_PATTERN = "^0[a-z0-9]{6}[0-9]{2}$";
+
+ // this is the number of items returned by the ROR API in each page
+ private static final int ROR_ITEMS_COUNT = 20;
+ // maximum number of pages that can be returned by the ROR API is 500
+ private static final int ROR_MAX_PAGES = 500;
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private static final Client client = ClientBuilder.newClient();
+
+ private String apiUrl;
+ private String clientId;
+
+ public void setApiUrl(String apiUrl) {
+ this.apiUrl = apiUrl;
+ }
+
+ public void setClientId(String clientId) {
+ this.clientId = clientId;
+ }
+
+ public Response getByQuery(String query) {
+ return getByQuery(query, 1);
+ }
+
+ public Response getByQuery(String query, int page) {
+ return client.target(apiUrl)
+ .queryParam("query", query)
+ .queryParam("page", page)
+ .request()
+ .header("Client-Id", clientId)
+ .accept("application/json")
+ .get();
+ }
+
+ public Response getByID(String rorID) {
+ if (rorID != null && rorID.matches(ROR_ID_PATTERN)) {
+ return client.target(apiUrl).path(rorID)
+ .request()
+ .header("Client-Id", clientId)
+ .accept("application/json")
+ .get();
+ } else {
+ return Response.status(Response.Status.NOT_FOUND).build();
+ }
+ }
+
+ @Cacheable(cacheNames = "ror-labels", key = "#rorID + '_' + #locale",
+ unless = "#result == null || #result.equals(#rorID)")
+ public String getLabel(String rorID, String locale) {
+ Choice choice = getChoice(rorID, locale);
+ return choice != null ? choice.label : rorID;
+ }
+
+ public Choices getMatches(String text, int start, int limit, String locale) {
+ if (text == null || text.trim().isEmpty()) {
+ return new Choices(true);
+ }
+
+ // allow only limits that are a divisor of ROR_RESULTS_COUNT(20),
+ // to avoid pagination complication in the UI
+ if (limit <= 0) {
+ limit = ROR_ITEMS_COUNT;
+ } else if (limit > ROR_ITEMS_COUNT || ROR_ITEMS_COUNT % limit != 0) {
+ throw new IllegalArgumentException("The page size must be a divisor of " + ROR_ITEMS_COUNT + ".");
+ }
+
+ // calculate the offset (page parameter) to use in the ROR API call
+ int offset = start / ROR_ITEMS_COUNT;
+
+ // if the offset is too high, it means the user is trying to access a page that doesn't exist,
+ // so we return an empty result instead of making an API call
+ if (offset + 1 > ROR_MAX_PAGES) {
+ throw new IllegalArgumentException("Exceeded maximal page number for the ROR API, which is " +
+ (ROR_MAX_PAGES * (ROR_ITEMS_COUNT / limit) - 1) + ", for page size " + limit + ".");
+ }
+
+ try (Response response = getByQuery(text, offset + 1)) {
+ if (response.getStatus() == Response.Status.OK.getStatusCode()) {
+ try (InputStream is = response.readEntity(InputStream.class)) {
+ RorItems rorItems = OBJECT_MAPPER.readValue(is, RorItems.class);
+ int total = rorItems.getNoOfResults();
+ List items = rorItems.getItems();
+ if (items.isEmpty()) {
+ return new Choices(new Choice[0], start, total, Choices.CF_NOTFOUND, false);
+ }
+
+ String localeLanguage = getLocaleLanguage(locale);
+
+ StoredNameType storedNameType = resolveStoredNameType();
+ List choices = items.stream()
+ .map(item -> toChoice(item, localeLanguage, storedNameType))
+ .collect(Collectors.toList());
+
+ // select sublist of results to return based on the start and limit parameters
+ int startIndex = 0;
+ if (limit != ROR_ITEMS_COUNT) {
+ startIndex = start % ROR_ITEMS_COUNT;
+ if (startIndex >= choices.size()) {
+ // the start index is greater than the choices size
+ // so we cannot select a sublist of results
+ return new Choices(new Choice[0], start, total, Choices.CF_NOTFOUND, false);
+ }
+ int endIndex = Math.min(startIndex + limit, choices.size());
+ choices = choices.subList(startIndex, endIndex);
+ }
+
+ int confidence = choices.isEmpty() ? Choices.CF_NOTFOUND :
+ choices.size() == 1 ? Choices.CF_UNCERTAIN : Choices.CF_AMBIGUOUS;
+
+ return new Choices(choices.toArray(Choice[]::new), start, total,
+ confidence, total > (offset * ROR_ITEMS_COUNT + startIndex + choices.size()));
+ } catch (Exception e) {
+ log.error("Error during search", e);
+ }
+ }
+ }
+ return new Choices(true);
+ }
+
+ public Choices getBestMatch(String text, String locale) {
+ if (text == null || text.trim().isEmpty()) {
+ return new Choices(true);
+ }
+ try (Response response = getByQuery(sanitizeQuery(text))) {
+ if (response.getStatus() == Response.Status.OK.getStatusCode()) {
+ try (InputStream is = response.readEntity(InputStream.class)) {
+ RorItems rorItems = OBJECT_MAPPER.readValue(is, RorItems.class);
+ List items = rorItems.getItems();
+ if (items.isEmpty()) {
+ return new Choices(false);
+ }
+ Choice[] choices = {toChoice(items.get(0), getLocaleLanguage(locale), resolveStoredNameType())};
+ return new Choices(choices, 0, 1, Choices.CF_UNCERTAIN, false);
+ } catch (Exception e) {
+ log.error("Error during search", e);
+ }
+ }
+ }
+
+ return new Choices(true);
+ }
+
+ public Choice getChoice(String authKey, String locale) {
+ try (Response response = getByID(authKey)) {
+ if (response.getStatus() == Response.Status.OK.getStatusCode()) {
+ try (InputStream is = response.readEntity(InputStream.class)) {
+ RorItem rorItem = OBJECT_MAPPER.readValue(is, RorItem.class);
+ return RorRestConnector.toChoice(rorItem, getLocaleLanguage(locale), resolveStoredNameType());
+ } catch (Exception e) {
+ log.error("Error during search", e);
+ }
+ }
+ }
+ return null;
+ }
+
+ private static String getLocaleLanguage(String locale) {
+ try {
+ return Optional.ofNullable(LocaleUtils.toLocale(locale)).map(Locale::getLanguage).orElse("en");
+ } catch (IllegalArgumentException e) {
+ log.warn("Invalid locale format: " + locale + ", using default 'en' locale.");
+ return "en";
+ }
+ }
+
+ private static Choice toChoice(RorItem rorItem, String localeLanguage, StoredNameType storedNameType) {
+ String authority = rorItem.getId();
+ int slashIndex = authority.lastIndexOf("/");
+ if (slashIndex != -1) {
+ authority = authority.substring(slashIndex + 1);
+ }
+
+ Choice c = new Choice();
+ c.authority = authority;
+
+ List names = rorItem.getNames();
+ if (!names.isEmpty()) {
+ String label = null;
+ String rorDisplay = null;
+ String enLabel = null;
+ StringBuilder aliases = new StringBuilder();
+ // the label quality is the following:
+ // 4 - locale label from labels, 3 - locale label from aliases, 2 - english label, 1 - any other label
+ int labelQuality = 0;
+ // the enLabelQuality is the following:
+ // 2 - english label from labels, 1 - english label from aliases
+ int enLabelQuality = 0;
+
+ for (RorItem.Name name : names) {
+ if (rorDisplay == null && name.getTypes().contains("ror_display")) {
+ rorDisplay = name.getValue();
+ }
+ if (name.getTypes().contains("label")) {
+ if (enLabelQuality < 2 && "en".equals(name.getLang())) {
+ enLabelQuality = 2;
+ enLabel = name.getValue();
+ }
+ if (labelQuality < 4 && localeLanguage.equals(name.getLang())) {
+ labelQuality = 4;
+ label = name.getValue();
+ } else if (labelQuality < 2 && "en".equals(name.getLang())) {
+ labelQuality = 2;
+ label = name.getValue();
+ } else if (labelQuality < 1) {
+ labelQuality = 1;
+ label = name.getValue();
+ }
+ }
+
+ if (name.getTypes().contains("alias")) {
+ String lang = name.getLang();
+ if (enLabelQuality < 1 && "en".equals(lang)) {
+ enLabelQuality = 1;
+ enLabel = name.getValue();
+ }
+ if (labelQuality < 3 && localeLanguage.equals(lang)) {
+ labelQuality = 3;
+ label = name.getValue();
+ }
+ if (aliases.length() > 0) {
+ aliases.append(", ");
+ }
+ aliases.append(name.getValue());
+ if (lang != null) {
+ aliases.append(" (").append(lang).append(")");
+ }
+ }
+ }
+
+ // fallback for label value if there is no label with type "label" in the ROR response
+ if (label == null) {
+ label = (rorDisplay != null) ? rorDisplay : names.get(0).getValue();
+ }
+
+ String value;
+ // set the value based on the configuration of the name selection type
+ switch (storedNameType) {
+ case ROR_DISPLAY : {
+ value = (rorDisplay != null) ? rorDisplay : label;
+ break;
+ }
+ case LOCALE_LABEL : {
+ value = label;
+ break;
+ }
+ default : {
+ value = enLabel != null ? enLabel : label;
+ }
+ }
+
+ c.label = label;
+ c.value = value;
+
+ c.extras.put("ror-id", authority);
+
+ // set other-name, if exists, to show it in the UI as additional information about the institution
+ if (aliases.length() > 0) {
+ c.extras.put("other-names", aliases.toString());
+ }
+
+ if (!rorItem.getLocations().isEmpty()) {
+ Location location = rorItem.getLocations().get(0);
+ Location.GeonamesDetails geonamesDetails = location.getGeonamesDetails();
+ if (geonamesDetails != null) {
+ c.extras.put("location", geonamesDetails.getName() + ", " +
+ geonamesDetails.getCountrySubdivisionName() + ", " +
+ geonamesDetails.getCountryName() + ", " +
+ geonamesDetails.getContinentName());
+ }
+ }
+
+ }
+ return c;
+ }
+
+ private static StoredNameType resolveStoredNameType() {
+ ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService();
+ return StoredNameType.fromString(
+ configurationService.getProperty("ror.authority.stored-name-type", "en_label"));
+ }
+
+ private static String sanitizeQuery(String query) {
+ if (query.startsWith("\"") && query.endsWith("\"")) {
+ return query;
+ } else {
+ return "\"" + query + "\"";
+ }
+ }
+
+ /**
+ * The type of the name that will be stored in the metadata,
+ * based on the configuration property "ror.authority.stored-name-type".
+ */
+ private enum StoredNameType {
+ ROR_DISPLAY,
+ EN_LABEL,
+ LOCALE_LABEL;
+
+ static StoredNameType fromString(String text) {
+ try {
+ return StoredNameType.valueOf(text.toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return EN_LABEL;
+ }
+ }
+ }
+
+}
diff --git a/dspace-api/src/main/java/org/dspace/external/model/ror/Location.java b/dspace-api/src/main/java/org/dspace/external/model/ror/Location.java
new file mode 100644
index 000000000000..3002a053aac9
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/external/model/ror/Location.java
@@ -0,0 +1,79 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+package org.dspace.external.model.ror;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Location model representing the single location element from ROR API response.
+ *
+ * @author Milan Kuchtiak
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class Location {
+ private final int geonamesId;
+ private final GeonamesDetails geonamesDetails;
+
+ public Location(@JsonProperty("geonames_id") int id,
+ @JsonProperty("geonames_details") GeonamesDetails geonamesDetails) {
+ this.geonamesId = id;
+ this.geonamesDetails = geonamesDetails;
+ }
+
+ public int getGeonamesId() {
+ return geonamesId;
+ }
+
+ public GeonamesDetails getGeonamesDetails() {
+ return geonamesDetails;
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class GeonamesDetails {
+
+ private final String name;
+ private final String countrySubdivisionName;
+ private final String countryCode;
+ private final String countryName;
+ private final String continentName;
+
+ public GeonamesDetails(@JsonProperty("name") String name,
+ @JsonProperty("country_subdivision_name") String countrySubdivisionName,
+ @JsonProperty("country_code") String countryCode,
+ @JsonProperty("country_name") String countryName,
+ @JsonProperty("continent_name") String continentName) {
+ this.name = name;
+ this.countrySubdivisionName = countrySubdivisionName;
+ this.countryCode = countryCode;
+ this.countryName = countryName;
+ this.continentName = continentName;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getCountrySubdivisionName() {
+ return countrySubdivisionName;
+ }
+
+ public String getCountryCode() {
+ return countryCode;
+ }
+
+ public String getCountryName() {
+ return countryName;
+ }
+
+ public String getContinentName() {
+ return continentName;
+ }
+ }
+
+}
diff --git a/dspace-api/src/main/java/org/dspace/external/model/ror/RorItem.java b/dspace-api/src/main/java/org/dspace/external/model/ror/RorItem.java
new file mode 100644
index 000000000000..fb2921343fe6
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/external/model/ror/RorItem.java
@@ -0,0 +1,92 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+package org.dspace.external.model.ror;
+
+import java.util.List;
+import java.util.Optional;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * ROR item model representing the single item from ROR API response.
+ *
+ * @author Milan Kuchtiak
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class RorItem {
+
+ private final String id;
+ private final List names;
+ private final String status;
+ private final String[] types;
+ private final List locations;
+
+ @JsonCreator()
+ public RorItem(@JsonProperty("id") String id,
+ @JsonProperty("names") List names,
+ @JsonProperty("locations") List locations,
+ @JsonProperty("status") String status,
+ @JsonProperty("types") String[] types) {
+ this.id = id;
+ this.names = Optional.ofNullable(names).orElse(List.of());
+ this.locations = Optional.ofNullable(locations).orElse(List.of());
+ this.status = status;
+ this.types = Optional.ofNullable(types).orElse(new String[0]);
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public List getNames() {
+ return names;
+ }
+
+ public List getLocations() {
+ return locations;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public String[] getTypes() {
+ return types;
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class Name {
+ private String lang;
+ private List types;
+ private String value;
+
+ @JsonCreator()
+ public Name(@JsonProperty("lang") String lang,
+ @JsonProperty("types") List types,
+ @JsonProperty("value") String value) {
+ this.lang = lang;
+ this.types = Optional.ofNullable(types).orElse(List.of());
+ this.value = value;
+ }
+
+ public String getLang() {
+ return lang;
+ }
+
+ public List getTypes() {
+ return types;
+ }
+
+ public String getValue() {
+ return value;
+ }
+ }
+
+}
diff --git a/dspace-api/src/main/java/org/dspace/external/model/ror/RorItems.java b/dspace-api/src/main/java/org/dspace/external/model/ror/RorItems.java
new file mode 100644
index 000000000000..7fdd38489faa
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/external/model/ror/RorItems.java
@@ -0,0 +1,50 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+package org.dspace.external.model.ror;
+
+import java.util.List;
+import java.util.Optional;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * ROR items model representing the ROR API response.
+ *
+ * @author Milan Kuchtiak
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class RorItems {
+
+ private final int noOfResults;
+ private final int timeTaken;
+ private final List items;
+
+ @JsonCreator()
+ public RorItems(@JsonProperty("number_of_results") int noOfResults,
+ @JsonProperty("time_taken") int timeTaken,
+ @JsonProperty("items") List items
+ ) {
+ this.noOfResults = noOfResults;
+ this.timeTaken = timeTaken;
+ this.items = Optional.ofNullable(items).orElse(List.of());
+ }
+
+ public int getNoOfResults() {
+ return noOfResults;
+ }
+
+ public int getTimeTaken() {
+ return timeTaken;
+ }
+
+ public List getItems() {
+ return items;
+ }
+}
diff --git a/dspace-api/src/main/java/org/dspace/external/ror/CacheLogger.java b/dspace-api/src/main/java/org/dspace/external/ror/CacheLogger.java
new file mode 100644
index 000000000000..2e47f1d6e7f7
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/external/ror/CacheLogger.java
@@ -0,0 +1,27 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+package org.dspace.external.ror;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.ehcache.event.CacheEvent;
+import org.ehcache.event.CacheEventListener;
+
+/**
+ * A simple logger for ROR label cache events
+ *
+ * @author Milan Kuchtiak
+ */
+public class CacheLogger implements CacheEventListener