From ce23ecc2855efa769810819b2305f9a83fe3bd86 Mon Sep 17 00:00:00 2001 From: Milan Kuchtiak Date: Tue, 30 Jun 2026 12:00:59 +0200 Subject: [PATCH 1/6] Issue ufal/clarin-dspace#1351 simple ror authority (ufal/clarin-dspace#1352) * Issue 1351: SimpleRORAuthority * integration test * code cleanup * add debug messages to find test failure * Revert "add debug messages to find test failure" This reverts commit 286fde46a39830cb45ba932fd00702b4c2c2bfa2. * test failures * Revert "test failures" This reverts commit 8808f5eead39720219f4a4c9e641c22f4f75052c. * resolve Copilot comments * resolve PR comments, add ROR lookup to Publisher field * rollback changes in VocabularyEntryLinkRepository * fixing failing tests * implement PR Comments * implementation improvement * resolve PR comments (O.Kosarko) * Address review nits: shared ObjectMapper, commons-lang3, IT cleanup - Reuse a single static ObjectMapper in SimpleRORAuthority instead of constructing one per call. - Switch to the non-deprecated org.apache.commons.lang3.LocaleUtils. - Reset the ChoiceAuthority plugin configuration in @AfterClass so VocabularyEntryLinkRepositoryIT no longer leaks the SimpleRORAuthority registration into other integration tests. * implement cache for gertLabel() * resolve PR Comments --------- Co-authored-by: Ondrej Kosarko (cherry picked from commit 25a5a25f023d9d4d9280a7b4bf7b8c7b32eca144) --- .../content/authority/SimpleRORAuthority.java | 119 + .../org/dspace/external/RorRestConnector.java | 346 +++ .../dspace/external/model/ror/Location.java | 79 + .../dspace/external/model/ror/RorItem.java | 91 + .../dspace/external/model/ror/RorItems.java | 49 + .../org/dspace/external/ror/CacheLogger.java | 27 + .../spring/api/ror-authority-services.xml | 15 + .../dspace/external/MockRorRestConnector.java | 69 + .../dspace/external/ror/UniversityOfPisa.json | 2172 +++++++++++++++++ .../external/ror/UniversityOfPisaByID.json | 128 + .../ror/UniversityOfPisaByQueryExact.json | 169 ++ .../rest/VocabularyEntryLinkRepositoryIT.java | 191 ++ dspace/config/ehcache.xml | 22 + dspace/config/features/enable-ror.cfg | 31 + .../spring/api/ror-authority-services.xml | 17 + 15 files changed, 3525 insertions(+) create mode 100644 dspace-api/src/main/java/org/dspace/content/authority/SimpleRORAuthority.java create mode 100644 dspace-api/src/main/java/org/dspace/external/RorRestConnector.java create mode 100644 dspace-api/src/main/java/org/dspace/external/model/ror/Location.java create mode 100644 dspace-api/src/main/java/org/dspace/external/model/ror/RorItem.java create mode 100644 dspace-api/src/main/java/org/dspace/external/model/ror/RorItems.java create mode 100644 dspace-api/src/main/java/org/dspace/external/ror/CacheLogger.java create mode 100644 dspace-api/src/test/data/dspaceFolder/config/spring/api/ror-authority-services.xml create mode 100644 dspace-api/src/test/java/org/dspace/external/MockRorRestConnector.java create mode 100644 dspace-api/src/test/resources/org/dspace/external/ror/UniversityOfPisa.json create mode 100644 dspace-api/src/test/resources/org/dspace/external/ror/UniversityOfPisaByID.json create mode 100644 dspace-api/src/test/resources/org/dspace/external/ror/UniversityOfPisaByQueryExact.json create mode 100644 dspace-server-webapp/src/test/java/org/dspace/app/rest/VocabularyEntryLinkRepositoryIT.java create mode 100644 dspace/config/features/enable-ror.cfg create mode 100644 dspace/config/spring/api/ror-authority-services.xml 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..5c2a116488a1 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java @@ -0,0 +1,346 @@ +/** + * 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.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") + 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) { + 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 aliasses + 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 tha 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..47fa1e4d56c6 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/external/model/ror/RorItem.java @@ -0,0 +1,91 @@ +/** + * 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 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 = names; + this.locations = locations; + this.status = status; + this.types = types; + } + + 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 = types; + 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..3393b4b87fe1 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/external/model/ror/RorItems.java @@ -0,0 +1,49 @@ +/** + * 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 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 = items; + } + + 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 { + private static final Logger log = LogManager.getLogger(CacheLogger.class); + @Override + public void onEvent(CacheEvent event) { + log.debug("ROR Cache Event Type: {} | Key: {} | Old Value: {} | New Value: {}", + event.getType(), event.getKey(), event.getOldValue(), event.getNewValue()); + } +} diff --git a/dspace-api/src/test/data/dspaceFolder/config/spring/api/ror-authority-services.xml b/dspace-api/src/test/data/dspaceFolder/config/spring/api/ror-authority-services.xml new file mode 100644 index 000000000000..711c8d6ce645 --- /dev/null +++ b/dspace-api/src/test/data/dspaceFolder/config/spring/api/ror-authority-services.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/dspace-api/src/test/java/org/dspace/external/MockRorRestConnector.java b/dspace-api/src/test/java/org/dspace/external/MockRorRestConnector.java new file mode 100644 index 000000000000..bc30f52110f4 --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/external/MockRorRestConnector.java @@ -0,0 +1,69 @@ +/** + * 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.util.Optional; +import javax.ws.rs.ProcessingException; +import javax.ws.rs.core.Configuration; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import org.glassfish.jersey.message.internal.OutboundJaxrsResponse; +import org.glassfish.jersey.message.internal.OutboundMessageContext; + +/** + * Mock implementation of RorRestConnector for testing purposes. + * It returns predefined responses based on the input query or ID. + * + * @author Milan Kuchtiak + */ +public class MockRorRestConnector extends RorRestConnector { + + @Override + public Response getByQuery(String query, int page) { + if (query != null && query.startsWith("\"")) { + return getMockResponse("/org/dspace/external/ror/UniversityOfPisaByQueryExact.json"); + } else { + return getMockResponse("/org/dspace/external/ror/UniversityOfPisa.json"); + } + } + + @Override + public Response getByID(String id) { + if (id.matches(ROR_ID_PATTERN)) { + return getMockResponse("/org/dspace/external/ror/UniversityOfPisaByID.json"); + } else { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + private static Response getMockResponse(String filePath) { + return new MockResponse<>(Response.Status.OK, + Optional.ofNullable(MockRorRestConnector.class.getResourceAsStream(filePath)) + .orElseThrow(() -> new IllegalStateException("Resource " + filePath + " not found."))); + } + + public static class MockResponse extends OutboundJaxrsResponse { + T responseBody; + + public MockResponse(Status status, T responseBody) { + super(status, new OutboundMessageContext((Configuration) null)); + this.responseBody = responseBody; + } + + @Override + public E readEntity(Class cls) throws ProcessingException { + return (E) responseBody; + } + + @Override + public MediaType getMediaType() { + return MediaType.APPLICATION_JSON_TYPE; + } + } +} diff --git a/dspace-api/src/test/resources/org/dspace/external/ror/UniversityOfPisa.json b/dspace-api/src/test/resources/org/dspace/external/ror/UniversityOfPisa.json new file mode 100644 index 000000000000..54040478d5e1 --- /dev/null +++ b/dspace-api/src/test/resources/org/dspace/external/ror/UniversityOfPisa.json @@ -0,0 +1,2172 @@ +{ + "number_of_results": 30133, + "time_taken": 59, + "items": [ + { + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2025-01-22", + "schema_version": "2.1" + } + }, + "domains": [ + "unipi.it" + ], + "established": 1343, + "external_ids": [ + { + "all": [ + "501100007514" + ], + "preferred": null, + "type": "fundref" + }, + { + "all": [ + "grid.5395.a" + ], + "preferred": "grid.5395.a", + "type": "grid" + }, + { + "all": [ + "0000 0004 1757 3729" + ], + "preferred": null, + "type": "isni" + }, + { + "all": [ + "Q645663" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/03ad39j10", + "links": [ + { + "type": "website", + "value": "https://www.unipi.it" + }, + { + "type": "wikipedia", + "value": "http://en.wikipedia.org/wiki/University_of_Pisa" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "IT", + "country_name": "Italy", + "country_subdivision_code": "52", + "country_subdivision_name": "Tuscany", + "lat": 43.70853, + "lng": 10.4036, + "name": "Pisa" + }, + "geonames_id": 3170647 + } + ], + "names": [ + { + "lang": null, + "types": [ + "acronym" + ], + "value": "UniPi" + }, + { + "lang": "en", + "types": [ + "ror_display", + "label" + ], + "value": "University of Pisa" + }, + { + "lang": "it", + "types": [ + "label" + ], + "value": "Università di Pisa" + }, + { + "lang": "de", + "types": [ + "label" + ], + "value": "Universität Pisa" + }, + { + "lang": "fr", + "types": [ + "label" + ], + "value": "Université de Pise" + } + ], + "relationships": [ + { + "label": "Ospedale Cisanello", + "type": "related", + "id": "https://ror.org/00mc91w09" + }, + { + "label": "Istituto Nazionale di Fisica Nucleare, Sezione di Pisa", + "type": "related", + "id": "https://ror.org/05symbg58" + } + ], + "status": "active", + "types": [ + "education", + "funder" + ] + }, + { + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2024-12-11", + "schema_version": "2.1" + } + }, + "domains": [], + "established": null, + "external_ids": [ + { + "all": [ + "grid.144189.1" + ], + "preferred": "grid.144189.1", + "type": "grid" + }, + { + "all": [ + "0000 0004 1756 8209" + ], + "preferred": null, + "type": "isni" + } + ], + "id": "https://ror.org/05xrcj819", + "links": [ + { + "type": "website", + "value": "http://www.ao-pisa.toscana.it/" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "IT", + "country_name": "Italy", + "country_subdivision_code": "52", + "country_subdivision_name": "Tuscany", + "lat": 43.70853, + "lng": 10.4036, + "name": "Pisa" + }, + "geonames_id": 3170647 + } + ], + "names": [ + { + "lang": "it", + "types": [ + "ror_display", + "label" + ], + "value": "Azienda Ospedaliera Universitaria Pisana" + }, + { + "lang": "en", + "types": [ + "label" + ], + "value": "University Hospital of Pisa" + } + ], + "relationships": [ + { + "label": "Ospedale Cisanello", + "type": "child", + "id": "https://ror.org/00mc91w09" + }, + { + "label": "ERN ReCONNET", + "type": "related", + "id": "https://ror.org/04069k268" + } + ], + "status": "active", + "types": [ + "healthcare" + ] + }, + { + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2024-12-11", + "schema_version": "2.1" + } + }, + "domains": [], + "established": 1992, + "external_ids": [ + { + "all": [ + "100007362", + "100007368" + ], + "preferred": null, + "type": "fundref" + }, + { + "all": [ + "grid.479041.f" + ], + "preferred": "grid.479041.f", + "type": "grid" + }, + { + "all": [ + "0000 0000 9587 6793" + ], + "preferred": null, + "type": "isni" + } + ], + "id": "https://ror.org/05jhnab13", + "links": [ + { + "type": "website", + "value": "http://www.fondazionepisa.it/" + }, + { + "type": "wikipedia", + "value": "https://en.wikipedia.org/wiki/Fondazione_Pisa" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "IT", + "country_name": "Italy", + "country_subdivision_code": "52", + "country_subdivision_name": "Tuscany", + "lat": 43.70853, + "lng": 10.4036, + "name": "Pisa" + }, + "geonames_id": 3170647 + } + ], + "names": [ + { + "lang": "it", + "types": [ + "alias" + ], + "value": "Fondazione Cassa di Risparmio di Pisa" + }, + { + "lang": "it", + "types": [ + "ror_display", + "label" + ], + "value": "Fondazione Pisa" + } + ], + "relationships": [], + "status": "active", + "types": [ + "funder", + "nonprofit" + ] + }, + { + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2025-01-22", + "schema_version": "2.1" + } + }, + "domains": [ + "pi.infn.it" + ], + "established": null, + "external_ids": [ + { + "all": [ + "grid.470216.6" + ], + "preferred": "grid.470216.6", + "type": "grid" + }, + { + "all": [ + "Q30265297" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/05symbg58", + "links": [ + { + "type": "website", + "value": "https://www.pi.infn.it" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "IT", + "country_name": "Italy", + "country_subdivision_code": "52", + "country_subdivision_name": "Tuscany", + "lat": 43.70853, + "lng": 10.4036, + "name": "Pisa" + }, + "geonames_id": 3170647 + } + ], + "names": [ + { + "lang": "it", + "types": [ + "alias" + ], + "value": "INFN Pisa" + }, + { + "lang": "en", + "types": [ + "alias" + ], + "value": "INFN Pisa Division" + }, + { + "lang": "en", + "types": [ + "alias" + ], + "value": "INFN Pisa Unit" + }, + { + "lang": "it", + "types": [ + "alias" + ], + "value": "INFN Sezione di Pisa" + }, + { + "lang": "it", + "types": [ + "acronym" + ], + "value": "INFN-PI" + }, + { + "lang": "it", + "types": [ + "label", + "ror_display" + ], + "value": "Istituto Nazionale di Fisica Nucleare, Sezione di Pisa" + }, + { + "lang": "en", + "types": [ + "label" + ], + "value": "National Institute for Nuclear Physics, Pisa Division" + } + ], + "relationships": [ + { + "label": "Istituto Nazionale di Fisica Nucleare", + "type": "parent", + "id": "https://ror.org/005ta0471" + }, + { + "label": "MAGIC Telescopes", + "type": "related", + "id": "https://ror.org/02w0r2764" + }, + { + "label": "University of Pisa", + "type": "related", + "id": "https://ror.org/03ad39j10" + } + ], + "status": "active", + "types": [ + "facility" + ] + }, + { + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2024-12-11", + "schema_version": "2.1" + } + }, + "domains": [], + "established": 1987, + "external_ids": [ + { + "all": [ + "grid.5740.6" + ], + "preferred": "grid.5740.6", + "type": "grid" + }, + { + "all": [ + "0000 0000 9120 5458" + ], + "preferred": null, + "type": "isni" + }, + { + "all": [ + "Q30252673" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/01t0n3b84", + "links": [ + { + "type": "website", + "value": "http://www.cpr.it/" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "IT", + "country_name": "Italy", + "country_subdivision_code": "52", + "country_subdivision_name": "Tuscany", + "lat": 43.70853, + "lng": 10.4036, + "name": "Pisa" + }, + "geonames_id": 3170647 + } + ], + "names": [ + { + "lang": "it", + "types": [ + "ror_display", + "label" + ], + "value": "Consorzio Pisa Ricerche" + } + ], + "relationships": [], + "status": "active", + "types": [ + "facility" + ] + }, + { + "admin": { + "created": { + "date": "2023-09-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2024-12-11", + "schema_version": "2.1" + } + }, + "domains": [], + "established": null, + "external_ids": [ + { + "all": [ + "0000 0004 1758 7813" + ], + "preferred": "0000 0004 1758 7813", + "type": "isni" + } + ], + "id": "https://ror.org/00vfm5970", + "links": [ + { + "type": "website", + "value": "https://www.pi.ingv.it" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "IT", + "country_name": "Italy", + "country_subdivision_code": "52", + "country_subdivision_name": "Tuscany", + "lat": 43.70853, + "lng": 10.4036, + "name": "Pisa" + }, + "geonames_id": 3170647 + } + ], + "names": [ + { + "lang": "it", + "types": [ + "ror_display", + "label" + ], + "value": "INGV Sezione di Pisa" + }, + { + "lang": null, + "types": [ + "acronym" + ], + "value": "INGV-PI" + }, + { + "lang": "it", + "types": [ + "alias" + ], + "value": "Istituto Nazionale di Geofisica e Vulcanologia Sezione di Pisa" + }, + { + "lang": "en", + "types": [ + "alias" + ], + "value": "National Institute of Geophysics and Volcanology, Pisa Section" + } + ], + "relationships": [ + { + "label": "Istituto Nazionale di Geofisica e Vulcanologia", + "type": "parent", + "id": "https://ror.org/00qps9a02" + } + ], + "status": "active", + "types": [ + "facility" + ] + }, + { + "admin": { + "created": { + "date": "2024-09-14", + "schema_version": "2.0" + }, + "last_modified": { + "date": "2024-12-11", + "schema_version": "2.1" + } + }, + "domains": [ + "liceodini.it" + ], + "established": 1924, + "external_ids": [ + { + "all": [ + "Q30889474" + ], + "preferred": "Q30889474", + "type": "wikidata" + } + ], + "id": "https://ror.org/006xg2x43", + "links": [ + { + "type": "website", + "value": "https://www.liceodini.it" + }, + { + "type": "wikipedia", + "value": "https://it.wikipedia.org/wiki/Liceo_scientifico_statale_Ulisse_Dini" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "IT", + "country_name": "Italy", + "country_subdivision_code": "52", + "country_subdivision_name": "Tuscany", + "lat": 43.70853, + "lng": 10.4036, + "name": "Pisa" + }, + "geonames_id": 3170647 + } + ], + "names": [ + { + "lang": "it", + "types": [ + "alias" + ], + "value": "Liceo Dini" + }, + { + "lang": "it", + "types": [ + "alias" + ], + "value": "Liceo Scientifico \"Ulisse Dini\"" + }, + { + "lang": "it", + "types": [ + "alias" + ], + "value": "Liceo Scientifico 'Ulisse Dini' - Pisa" + }, + { + "lang": "it", + "types": [ + "alias" + ], + "value": "Liceo Scientifico Ulisse Dini" + }, + { + "lang": "it", + "types": [ + "label", + "ror_display" + ], + "value": "Liceo scientifico statale Ulisse Dini" + }, + { + "lang": "it", + "types": [ + "alias" + ], + "value": "U. Dini" + }, + { + "lang": "en", + "types": [ + "label" + ], + "value": "Ulisse Dini Scientific High School" + } + ], + "relationships": [], + "status": "active", + "types": [ + "education" + ] + }, + { + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2024-12-11", + "schema_version": "2.1" + } + }, + "domains": [ + "sns.it" + ], + "established": 1810, + "external_ids": [ + { + "all": [ + "100009093" + ], + "preferred": null, + "type": "fundref" + }, + { + "all": [ + "grid.6093.c" + ], + "preferred": "grid.6093.c", + "type": "grid" + }, + { + "all": [ + "Q672416" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/03aydme10", + "links": [ + { + "type": "website", + "value": "https://www.sns.it" + }, + { + "type": "wikipedia", + "value": "http://en.wikipedia.org/wiki/Scuola_Normale_Superiore_di_Pisa" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "IT", + "country_name": "Italy", + "country_subdivision_code": "52", + "country_subdivision_name": "Tuscany", + "lat": 43.70853, + "lng": 10.4036, + "name": "Pisa" + }, + "geonames_id": 3170647 + } + ], + "names": [ + { + "lang": null, + "types": [ + "acronym" + ], + "value": "SNS" + }, + { + "lang": "it", + "types": [ + "ror_display", + "label" + ], + "value": "Scuola Normale Superiore" + }, + { + "lang": "it", + "types": [ + "alias" + ], + "value": "Scuola Normale Superiore di Pisa" + }, + { + "lang": "fr", + "types": [ + "alias" + ], + "value": "École Normale Supérieure de Pise" + } + ], + "relationships": [ + { + "label": "National Enterprise for NanoScience and NanoTechnology", + "type": "child", + "id": "https://ror.org/01sgfhb12" + } + ], + "status": "active", + "types": [ + "education", + "funder" + ] + }, + { + "admin": { + "created": { + "date": "2024-10-29", + "schema_version": "2.0" + }, + "last_modified": { + "date": "2024-12-11", + "schema_version": "2.1" + } + }, + "domains": [], + "established": null, + "external_ids": [], + "id": "https://ror.org/05etrbr47", + "links": [ + { + "type": "website", + "value": "https://web.infn.it/GC-Siena" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "IT", + "country_name": "Italy", + "country_subdivision_code": "52", + "country_subdivision_name": "Tuscany", + "lat": 43.31822, + "lng": 11.33064, + "name": "Siena" + }, + "geonames_id": 3166548 + } + ], + "names": [ + { + "lang": "it", + "types": [ + "alias" + ], + "value": "INFN Gruppo Collegato di Siena" + }, + { + "lang": "it", + "types": [ + "alias" + ], + "value": "INFN Gruppo Collegato di Siena a INFN-Pisa" + }, + { + "lang": "it", + "types": [ + "acronym" + ], + "value": "INFN-GCSI" + }, + { + "lang": "it", + "types": [ + "label", + "ror_display" + ], + "value": "Istituto Nazionale di Fisica Nucleare, Gruppo Collegato di Siena" + } + ], + "relationships": [ + { + "label": "Istituto Nazionale di Fisica Nucleare, Sezione di Firenze", + "type": "parent", + "id": "https://ror.org/02vv5y108" + }, + { + "label": "University of Siena", + "type": "related", + "id": "https://ror.org/01tevnk56" + } + ], + "status": "active", + "types": [ + "education", + "facility" + ] + }, + { + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2024-12-11", + "schema_version": "2.1" + } + }, + "domains": [], + "established": 1599, + "external_ids": [ + { + "all": [ + "grid.440820.a" + ], + "preferred": "grid.440820.a", + "type": "grid" + }, + { + "all": [ + "Q935460" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/006zjws59", + "links": [ + { + "type": "website", + "value": "http://www.uvic-ucc.cat/en" + }, + { + "type": "wikipedia", + "value": "https://en.wikipedia.org/wiki/University_of_Vic_-_Central_University_of_Catalonia" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "ES", + "country_name": "Spain", + "country_subdivision_code": "CT", + "country_subdivision_name": "Catalonia", + "lat": 41.93012, + "lng": 2.25486, + "name": "Vic" + }, + "geonames_id": 3106050 + } + ], + "names": [ + { + "lang": null, + "types": [ + "acronym" + ], + "value": "UVic-UCC" + }, + { + "lang": "es", + "types": [ + "alias" + ], + "value": "Universidad de Vic" + }, + { + "lang": "es", + "types": [ + "label" + ], + "value": "Universidad de Vic - Universidad Central de Catalunya" + }, + { + "lang": "es", + "types": [ + "alias" + ], + "value": "Universitat de Vic" + }, + { + "lang": "ca", + "types": [ + "ror_display", + "label" + ], + "value": "Universitat de Vic - Universitat Central de Catalunya" + }, + { + "lang": "en", + "types": [ + "alias" + ], + "value": "University of Vic" + }, + { + "lang": "en", + "types": [ + "label" + ], + "value": "University of Vic - Central University of Catalonia" + } + ], + "relationships": [], + "status": "active", + "types": [ + "education" + ] + }, + { + "admin": { + "created": { + "date": "2019-02-17", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2024-12-11", + "schema_version": "2.1" + } + }, + "domains": [], + "established": 1957, + "external_ids": [ + { + "all": [ + "grid.501720.1" + ], + "preferred": "grid.501720.1", + "type": "grid" + }, + { + "all": [ + "Q10829127" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/04bm3wy68", + "links": [ + { + "type": "website", + "value": "http://www.dhsphue.edu.vn" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "AS", + "continent_name": "Asia", + "country_code": "VN", + "country_name": "Vietnam", + "country_subdivision_code": "26", + "country_subdivision_name": "Thừa Thiên Huế Province", + "lat": 16.4619, + "lng": 107.59546, + "name": "Huế" + }, + "geonames_id": 1580240 + } + ], + "names": [ + { + "lang": "en", + "types": [ + "alias" + ], + "value": "Hue University" + }, + { + "lang": "en", + "types": [ + "label", + "ror_display" + ], + "value": "Hue University of Education" + }, + { + "lang": "en", + "types": [ + "alias" + ], + "value": "Hue University's College of Education" + }, + { + "lang": "vi", + "types": [ + "label" + ], + "value": "Trường Đại học Sư phạm Huế" + }, + { + "lang": "en", + "types": [ + "label" + ], + "value": "University of Education, Hue University" + } + ], + "relationships": [], + "status": "active", + "types": [ + "education" + ] + }, + { + "admin": { + "created": { + "date": "2025-10-27", + "schema_version": "2.1" + }, + "last_modified": { + "date": "2025-10-28", + "schema_version": "2.1" + } + }, + "domains": [ + "pnc.edu.ph" + ], + "established": 2003, + "external_ids": [ + { + "all": [ + "0000 0005 0599 0581" + ], + "preferred": "0000 0005 0599 0581", + "type": "isni" + }, + { + "all": [ + "Q7129041" + ], + "preferred": "Q7129041", + "type": "wikidata" + } + ], + "id": "https://ror.org/05h0cmr57", + "links": [ + { + "type": "wikipedia", + "value": "https://en.wikipedia.org/wiki/University_of_Cabuyao" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "AS", + "continent_name": "Asia", + "country_code": "PH", + "country_name": "Philippines", + "country_subdivision_code": "40", + "country_subdivision_name": "Calabarzon", + "lat": 14.2726, + "lng": 121.1262, + "name": "Cabuyao" + }, + "geonames_id": 1721281 + } + ], + "names": [ + { + "lang": "tl", + "types": [ + "label", + "ror_display" + ], + "value": "Pamantasan ng Cabuyao" + }, + { + "lang": "tl", + "types": [ + "acronym" + ], + "value": "PnC" + }, + { + "lang": "en", + "types": [ + "label" + ], + "value": "University of Cabuyao" + } + ], + "relationships": [], + "status": "active", + "types": [ + "education" + ] + }, + { + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2025-10-28", + "schema_version": "2.1" + } + }, + "domains": [ + "luguniv.edu.ua" + ], + "established": 1921, + "external_ids": [ + { + "all": [ + "grid.445812.e" + ], + "preferred": "grid.445812.e", + "type": "grid" + }, + { + "all": [ + "0000 0004 0489 542X" + ], + "preferred": null, + "type": "isni" + }, + { + "all": [ + "Q4267928" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/040wb2y55", + "links": [ + { + "type": "website", + "value": "https://luguniv.edu.ua" + }, + { + "type": "wikipedia", + "value": "https://en.wikipedia.org/wiki/University_of_Luhansk" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "UA", + "country_name": "Ukraine", + "country_subdivision_code": "09", + "country_subdivision_name": "Luhansk", + "lat": 48.56814, + "lng": 39.30553, + "name": "Luhansk" + }, + "geonames_id": 702658 + } + ], + "names": [ + { + "lang": null, + "types": [ + "acronym" + ], + "value": "LNU" + }, + { + "lang": "en", + "types": [ + "alias" + ], + "value": "Taras Shevchenko National University of Luhansk" + }, + { + "lang": null, + "types": [ + "ror_display", + "label" + ], + "value": "University of Luhansk" + }, + { + "lang": "pl", + "types": [ + "label" + ], + "value": "Ługański Uniwersytet Narodowy im. Tarasa Szewczenki" + }, + { + "lang": "ru", + "types": [ + "label" + ], + "value": "Луганский национальный университет имени Тараса Шевченко" + }, + { + "lang": "uk", + "types": [ + "label" + ], + "value": "Луганський національний університет імені Тараса Шевченка" + } + ], + "relationships": [], + "status": "active", + "types": [ + "education" + ] + }, + { + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2025-10-28", + "schema_version": "2.1" + } + }, + "domains": [ + "univ-parakou.bj" + ], + "established": 2001, + "external_ids": [ + { + "all": [ + "grid.440525.2" + ], + "preferred": "grid.440525.2", + "type": "grid" + }, + { + "all": [ + "0000 0004 0457 5047" + ], + "preferred": null, + "type": "isni" + }, + { + "all": [ + "Q3551659" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/025wndx93", + "links": [ + { + "type": "website", + "value": "https://www.univ-parakou.bj" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "AF", + "continent_name": "Africa", + "country_code": "BJ", + "country_name": "Benin", + "country_subdivision_code": "BO", + "country_subdivision_name": "Borgou", + "lat": 9.33716, + "lng": 2.63031, + "name": "Parakou" + }, + "geonames_id": 2392204 + } + ], + "names": [ + { + "lang": null, + "types": [ + "acronym" + ], + "value": "UP" + }, + { + "lang": "en", + "types": [ + "label" + ], + "value": "University of Parakou" + }, + { + "lang": "fr", + "types": [ + "ror_display", + "label" + ], + "value": "Université de Parakou" + } + ], + "relationships": [], + "status": "active", + "types": [ + "education" + ] + }, + { + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2025-10-28", + "schema_version": "2.1" + } + }, + "domains": [ + "univ-bangui.org" + ], + "established": 1969, + "external_ids": [ + { + "all": [ + "grid.25077.37" + ], + "preferred": "grid.25077.37", + "type": "grid" + }, + { + "all": [ + "0000 0000 9737 7808" + ], + "preferred": null, + "type": "isni" + }, + { + "all": [ + "Q1638914" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/020q46z35", + "links": [ + { + "type": "website", + "value": "https://www.univ-bangui.org/" + }, + { + "type": "wikipedia", + "value": "https://en.wikipedia.org/wiki/University_of_Bangui" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "AF", + "continent_name": "Africa", + "country_code": "CF", + "country_name": "Central African Republic", + "country_subdivision_code": "BGF", + "country_subdivision_name": "Bangui", + "lat": 4.36122, + "lng": 18.55496, + "name": "Bangui" + }, + "geonames_id": 2389853 + } + ], + "names": [ + { + "lang": "en", + "types": [ + "ror_display", + "label" + ], + "value": "University of Bangui" + }, + { + "lang": "fr", + "types": [ + "label" + ], + "value": "Université de Bangui" + } + ], + "relationships": [], + "status": "active", + "types": [ + "education" + ] + }, + { + "admin": { + "created": { + "date": "2025-06-24", + "schema_version": "2.1" + }, + "last_modified": { + "date": "2025-10-28", + "schema_version": "2.1" + } + }, + "domains": [ + "uomanara.edu.iq" + ], + "established": 2017, + "external_ids": [], + "id": "https://ror.org/04k20kq32", + "links": [ + { + "type": "website", + "value": "https://uomanara.edu.iq" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "AS", + "continent_name": "Asia", + "country_code": "IQ", + "country_name": "Iraq", + "country_subdivision_code": "MA", + "country_subdivision_name": "Maysan", + "lat": 31.9, + "lng": 47.06667, + "name": "Maysan" + }, + "geonames_id": 93540 + } + ], + "names": [ + { + "lang": "en", + "types": [ + "alias" + ], + "value": "Al-Manara College for Medical Sciences" + }, + { + "lang": "en", + "types": [ + "alias" + ], + "value": "Al-Manara University" + }, + { + "lang": "en", + "types": [ + "label", + "ror_display" + ], + "value": "University of Manara" + }, + { + "lang": "ar", + "types": [ + "label" + ], + "value": "جامعة المنارة" + }, + { + "lang": "ar", + "types": [ + "alias" + ], + "value": "لكلية المنارة للعلوم الطبية" + } + ], + "relationships": [], + "status": "active", + "types": [ + "education" + ] + }, + { + "admin": { + "created": { + "date": "2020-03-15", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2025-10-28", + "schema_version": "2.1" + } + }, + "domains": [ + "uok.ac.rw" + ], + "established": 2013, + "external_ids": [ + { + "all": [ + "grid.507637.0" + ], + "preferred": "grid.507637.0", + "type": "grid" + }, + { + "all": [ + "0000 0004 4676 8461" + ], + "preferred": null, + "type": "isni" + }, + { + "all": [ + "Q48773691" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/03v842g47", + "links": [ + { + "type": "website", + "value": "https://uok.ac.rw" + }, + { + "type": "wikipedia", + "value": "https://en.wikipedia.org/wiki/University_of_Kigali" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "AF", + "continent_name": "Africa", + "country_code": "RW", + "country_name": "Rwanda", + "country_subdivision_code": "01", + "country_subdivision_name": "Kigali", + "lat": -1.94995, + "lng": 30.05885, + "name": "Kigali" + }, + "geonames_id": 202061 + } + ], + "names": [ + { + "lang": "en", + "types": [ + "ror_display", + "label" + ], + "value": "University of Kigali" + }, + { + "lang": null, + "types": [ + "acronym" + ], + "value": "UoK" + } + ], + "relationships": [], + "status": "active", + "types": [ + "education" + ] + }, + { + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2025-10-28", + "schema_version": "2.1" + } + }, + "domains": [ + "unifa.ac.id" + ], + "established": 2008, + "external_ids": [ + { + "all": [ + "grid.443675.7" + ], + "preferred": "grid.443675.7", + "type": "grid" + }, + { + "all": [ + "0000 0004 0386 0305" + ], + "preferred": null, + "type": "isni" + }, + { + "all": [ + "Q23807190" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/05whqt140", + "links": [ + { + "type": "website", + "value": "https://unifa.ac.id" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "AS", + "continent_name": "Asia", + "country_code": "ID", + "country_name": "Indonesia", + "country_subdivision_code": "SN", + "country_subdivision_name": "South Sulawesi", + "lat": -5.14861, + "lng": 119.43194, + "name": "Makassar" + }, + "geonames_id": 1622786 + } + ], + "names": [ + { + "lang": null, + "types": [ + "acronym" + ], + "value": "UNIFA" + }, + { + "lang": "id", + "types": [ + "ror_display", + "label" + ], + "value": "Universitas Fajar" + }, + { + "lang": "en", + "types": [ + "alias" + ], + "value": "University of Dawn" + } + ], + "relationships": [], + "status": "active", + "types": [ + "education" + ] + }, + { + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2025-10-28", + "schema_version": "2.1" + } + }, + "domains": [ + "uni.lu" + ], + "established": 2003, + "external_ids": [ + { + "all": [ + "100008665" + ], + "preferred": null, + "type": "fundref" + }, + { + "all": [ + "grid.16008.3f" + ], + "preferred": "grid.16008.3f", + "type": "grid" + }, + { + "all": [ + "0000 0001 2295 9843" + ], + "preferred": null, + "type": "isni" + }, + { + "all": [ + "Q59668" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/036x5ad56", + "links": [ + { + "type": "website", + "value": "https://www.uni.lu" + }, + { + "type": "wikipedia", + "value": "http://en.wikipedia.org/wiki/University_of_Luxembourg" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "LU", + "country_name": "Luxembourg", + "country_subdivision_code": "LU", + "country_subdivision_name": "Luxembourg", + "lat": 49.60982, + "lng": 6.13268, + "name": "Luxembourg" + }, + "geonames_id": 2960316 + } + ], + "names": [ + { + "lang": "en", + "types": [ + "ror_display", + "label" + ], + "value": "University of Luxembourg" + }, + { + "lang": "de", + "types": [ + "label" + ], + "value": "Universität Luxemburg" + }, + { + "lang": "fr", + "types": [ + "label" + ], + "value": "Université du Luxembourg" + } + ], + "relationships": [ + { + "label": "Luxembourg Centre for Contemporary and Digital History", + "type": "child", + "id": "https://ror.org/054b6pr16" + }, + { + "label": "Luxembourg Centre for Systems Biomedicine", + "type": "child", + "id": "https://ror.org/051tr1y59" + }, + { + "label": "Interdisciplinary Centre for Security, Reliability and Trust", + "type": "child", + "id": "https://ror.org/02qav7c04" + }, + { + "label": "Luxembourg Centre for Socio-Environmental Systems", + "type": "child", + "id": "https://ror.org/04c6fdf96" + }, + { + "label": "Luxembourg Centre for European Law", + "type": "child", + "id": "https://ror.org/04cqhc152" + }, + { + "label": "Centre Hospitalier de Luxembourg", + "type": "related", + "id": "https://ror.org/03xq7w797" + } + ], + "status": "active", + "types": [ + "education", + "funder" + ] + }, + { + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2025-10-28", + "schema_version": "2.1" + } + }, + "domains": [ + "uma.pt" + ], + "established": 1988, + "external_ids": [ + { + "all": [ + "501100013990" + ], + "preferred": "501100013990", + "type": "fundref" + }, + { + "all": [ + "grid.26793.39" + ], + "preferred": "grid.26793.39", + "type": "grid" + }, + { + "all": [ + "0000 0001 2155 1272" + ], + "preferred": null, + "type": "isni" + }, + { + "all": [ + "Q1434847" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/0442zbe52", + "links": [ + { + "type": "website", + "value": "https://www.uma.pt/" + }, + { + "type": "wikipedia", + "value": "http://en.wikipedia.org/wiki/University_of_Madeira" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "PT", + "country_name": "Portugal", + "country_subdivision_code": "30", + "country_subdivision_name": "Madeira", + "lat": 32.66568, + "lng": -16.92547, + "name": "Funchal" + }, + "geonames_id": 2267827 + } + ], + "names": [ + { + "lang": null, + "types": [ + "acronym" + ], + "value": "UMa" + }, + { + "lang": "pt", + "types": [ + "ror_display", + "label" + ], + "value": "Universidade da Madeira" + }, + { + "lang": "en", + "types": [ + "label" + ], + "value": "University of Madeira" + } + ], + "relationships": [ + { + "label": "Centro de Investigação de Matemática e Aplicações", + "type": "child", + "id": "https://ror.org/058k6gb21" + }, + { + "label": "Centro de Investigação em Educação", + "type": "child", + "id": "https://ror.org/00wdyvz26" + }, + { + "label": "Madeira N-Lincs", + "type": "child", + "id": "https://ror.org/04kt8mw18" + }, + { + "label": "Centro de Ciências Matemáticas", + "type": "child", + "id": "https://ror.org/04ycf0k71" + }, + { + "label": "Centro de Investigação em Estudos Regionais e Locais", + "type": "child", + "id": "https://ror.org/01551d523" + }, + { + "label": "Centro de Química da Madeira", + "type": "child", + "id": "https://ror.org/01tgdcv71" + }, + { + "label": "Grupo de Astronomia", + "type": "child", + "id": "https://ror.org/01hbpef95" + }, + { + "label": "ISOPlexis Banco de Germoplasma", + "type": "child", + "id": "https://ror.org/02c4ps936" + } + ], + "status": "active", + "types": [ + "education", + "funder" + ] + } + ], + "meta": { + "types": [ + { + "id": "education", + "title": "education", + "count": 15003 + }, + { + "id": "funder", + "title": "funder", + "count": 7064 + }, + { + "id": "facility", + "title": "facility", + "count": 4233 + }, + { + "id": "government", + "title": "government", + "count": 3315 + }, + { + "id": "nonprofit", + "title": "nonprofit", + "count": 2599 + }, + { + "id": "healthcare", + "title": "healthcare", + "count": 2088 + }, + { + "id": "other", + "title": "other", + "count": 1847 + }, + { + "id": "archive", + "title": "archive", + "count": 733 + }, + { + "id": "company", + "title": "company", + "count": 362 + } + ], + "countries": [ + { + "id": "us", + "title": "United States", + "count": 5564 + }, + { + "id": "cn", + "title": "China", + "count": 2749 + }, + { + "id": "jp", + "title": "Japan", + "count": 1872 + }, + { + "id": "in", + "title": "India", + "count": 1763 + }, + { + "id": "ru", + "title": "Russia", + "count": 1559 + }, + { + "id": "gb", + "title": "United Kingdom", + "count": 871 + }, + { + "id": "kr", + "title": "South Korea", + "count": 829 + }, + { + "id": "de", + "title": "Germany", + "count": 702 + }, + { + "id": "fr", + "title": "France", + "count": 702 + }, + { + "id": "ca", + "title": "Canada", + "count": 608 + } + ], + "continents": [ + { + "id": "as", + "title": "Asia", + "count": 11352 + }, + { + "id": "eu", + "title": "Europe", + "count": 9012 + }, + { + "id": "na", + "title": "North America", + "count": 6649 + }, + { + "id": "af", + "title": "Africa", + "count": 1816 + }, + { + "id": "sa", + "title": "South America", + "count": 826 + }, + { + "id": "oc", + "title": "Oceania", + "count": 479 + } + ], + "statuses": [ + { + "id": "active", + "title": "active", + "count": 30133 + } + ] + } +} \ No newline at end of file diff --git a/dspace-api/src/test/resources/org/dspace/external/ror/UniversityOfPisaByID.json b/dspace-api/src/test/resources/org/dspace/external/ror/UniversityOfPisaByID.json new file mode 100644 index 000000000000..84f2c738492f --- /dev/null +++ b/dspace-api/src/test/resources/org/dspace/external/ror/UniversityOfPisaByID.json @@ -0,0 +1,128 @@ +{ + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2025-01-22", + "schema_version": "2.1" + } + }, + "domains": [ + "unipi.it" + ], + "established": 1343, + "external_ids": [ + { + "all": [ + "501100007514" + ], + "preferred": null, + "type": "fundref" + }, + { + "all": [ + "grid.5395.a" + ], + "preferred": "grid.5395.a", + "type": "grid" + }, + { + "all": [ + "0000 0004 1757 3729" + ], + "preferred": null, + "type": "isni" + }, + { + "all": [ + "Q645663" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/03ad39j10", + "links": [ + { + "type": "website", + "value": "https://www.unipi.it" + }, + { + "type": "wikipedia", + "value": "http://en.wikipedia.org/wiki/University_of_Pisa" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "IT", + "country_name": "Italy", + "country_subdivision_code": "52", + "country_subdivision_name": "Tuscany", + "lat": 43.70853, + "lng": 10.4036, + "name": "Pisa" + }, + "geonames_id": 3170647 + } + ], + "names": [ + { + "lang": null, + "types": [ + "acronym" + ], + "value": "UniPi" + }, + { + "lang": "en", + "types": [ + "ror_display", + "label" + ], + "value": "University of Pisa" + }, + { + "lang": "it", + "types": [ + "label" + ], + "value": "Università di Pisa" + }, + { + "lang": "de", + "types": [ + "label" + ], + "value": "Universität Pisa" + }, + { + "lang": "fr", + "types": [ + "label" + ], + "value": "Université de Pise" + } + ], + "relationships": [ + { + "label": "Ospedale Cisanello", + "type": "related", + "id": "https://ror.org/00mc91w09" + }, + { + "label": "Istituto Nazionale di Fisica Nucleare, Sezione di Pisa", + "type": "related", + "id": "https://ror.org/05symbg58" + } + ], + "status": "active", + "types": [ + "education", + "funder" + ] +} \ No newline at end of file diff --git a/dspace-api/src/test/resources/org/dspace/external/ror/UniversityOfPisaByQueryExact.json b/dspace-api/src/test/resources/org/dspace/external/ror/UniversityOfPisaByQueryExact.json new file mode 100644 index 000000000000..d4cbd31353b7 --- /dev/null +++ b/dspace-api/src/test/resources/org/dspace/external/ror/UniversityOfPisaByQueryExact.json @@ -0,0 +1,169 @@ +{ + "number_of_results": 1, + "time_taken": 7, + "items": [ + { + "admin": { + "created": { + "date": "2018-11-14", + "schema_version": "1.0" + }, + "last_modified": { + "date": "2025-01-22", + "schema_version": "2.1" + } + }, + "domains": [ + "unipi.it" + ], + "established": 1343, + "external_ids": [ + { + "all": [ + "501100007514" + ], + "preferred": null, + "type": "fundref" + }, + { + "all": [ + "grid.5395.a" + ], + "preferred": "grid.5395.a", + "type": "grid" + }, + { + "all": [ + "0000 0004 1757 3729" + ], + "preferred": null, + "type": "isni" + }, + { + "all": [ + "Q645663" + ], + "preferred": null, + "type": "wikidata" + } + ], + "id": "https://ror.org/03ad39j10", + "links": [ + { + "type": "website", + "value": "https://www.unipi.it" + }, + { + "type": "wikipedia", + "value": "http://en.wikipedia.org/wiki/University_of_Pisa" + } + ], + "locations": [ + { + "geonames_details": { + "continent_code": "EU", + "continent_name": "Europe", + "country_code": "IT", + "country_name": "Italy", + "country_subdivision_code": "52", + "country_subdivision_name": "Tuscany", + "lat": 43.70853, + "lng": 10.4036, + "name": "Pisa" + }, + "geonames_id": 3170647 + } + ], + "names": [ + { + "lang": null, + "types": [ + "acronym" + ], + "value": "UniPi" + }, + { + "lang": "en", + "types": [ + "ror_display", + "label" + ], + "value": "University of Pisa" + }, + { + "lang": "it", + "types": [ + "label" + ], + "value": "Università di Pisa" + }, + { + "lang": "de", + "types": [ + "label" + ], + "value": "Universität Pisa" + }, + { + "lang": "fr", + "types": [ + "label" + ], + "value": "Université de Pise" + } + ], + "relationships": [ + { + "label": "Ospedale Cisanello", + "type": "related", + "id": "https://ror.org/00mc91w09" + }, + { + "label": "Istituto Nazionale di Fisica Nucleare, Sezione di Pisa", + "type": "related", + "id": "https://ror.org/05symbg58" + } + ], + "status": "active", + "types": [ + "education", + "funder" + ] + } + ], + "meta": { + "types": [ + { + "id": "education", + "title": "education", + "count": 1 + }, + { + "id": "funder", + "title": "funder", + "count": 1 + } + ], + "countries": [ + { + "id": "it", + "title": "Italy", + "count": 1 + } + ], + "continents": [ + { + "id": "eu", + "title": "Europe", + "count": 1 + } + ], + "statuses": [ + { + "id": "active", + "title": "active", + "count": 1 + } + ] + } +} \ No newline at end of file diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/VocabularyEntryLinkRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/VocabularyEntryLinkRepositoryIT.java new file mode 100644 index 000000000000..8f798ba18271 --- /dev/null +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/VocabularyEntryLinkRepositoryIT.java @@ -0,0 +1,191 @@ +/** + * 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.app.rest; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Objects; + +import org.dspace.app.rest.test.AbstractControllerIntegrationTest; +import org.dspace.core.factory.CoreServiceFactory; +import org.dspace.services.ConfigurationService; +import org.dspace.services.factory.DSpaceServicesFactory; +import org.hamcrest.Matchers; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; +import org.springframework.test.web.servlet.ResultActions; + +public class VocabularyEntryLinkRepositoryIT extends AbstractControllerIntegrationTest { + + private static final String BASE_VOCABULARY_URL = "/api/submission/vocabularies"; + private static final String ROR_AUTHORITY_ENTRIES_URL = BASE_VOCABULARY_URL + "/SimpleRORAuthority/entries"; + private static final int MOCK_TOTAL_ELEMENTS = 30133; + private static final String CHOICE_AUTHORITY_PLUGIN_KEY = + "plugin.named.org.dspace.content.authority.ChoiceAuthority"; + + private static String[] originalChoiceAuthorities; + + @BeforeClass + public static void beforeClass() { + ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + originalChoiceAuthorities = configurationService.getArrayProperty(CHOICE_AUTHORITY_PLUGIN_KEY); + configurationService.setProperty(CHOICE_AUTHORITY_PLUGIN_KEY, + new String[] { + "org.dspace.content.authority.SimpleRORAuthority = SimpleRORAuthority" + }); + CoreServiceFactory.getInstance().getPluginService().clearNamedPluginClasses(); + } + + @AfterClass + public static void afterClass() { + // restore the original ChoiceAuthority plugin configuration so this class does not + // leak the SimpleRORAuthority registration into other integration tests + ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + configurationService.setProperty(CHOICE_AUTHORITY_PLUGIN_KEY, originalChoiceAuthorities); + CoreServiceFactory.getInstance().getPluginService().clearNamedPluginClasses(); + } + + @Test + public void rorAuthoritySizeNotDivisorOf20() throws Exception { + getClient().perform(get(ROR_AUTHORITY_ENTRIES_URL) + .param("filter", "University") + .param("size", "3")) + .andExpect(status().isBadRequest()) + .andExpect(result -> Assert.assertEquals( + "The page size must be a divisor of 20.", + Objects.requireNonNull(result.getResolvedException()).getMessage())); + } + + @Test + public void rorAuthorityTooManyPages() throws Exception { + getClient().perform(get(ROR_AUTHORITY_ENTRIES_URL) + .param("filter", "University") + .param("page", "500")) + .andExpect(status().isBadRequest()) + .andExpect(result -> Assert.assertEquals( + "Exceeded maximal page number for the ROR API, which is 499, for page size 20.", + Objects.requireNonNull(result.getResolvedException()).getMessage())); + } + + @Test + public void rorAuthorityTooManyPagesForSize4() throws Exception { + getClient().perform(get(ROR_AUTHORITY_ENTRIES_URL) + .param("filter", "University") + .param("size", "4") + .param("page", "2500")) + .andExpect(status().isBadRequest()) + .andExpect(result -> Assert.assertEquals( + "Exceeded maximal page number for the ROR API, which is 2499, for page size 4.", + Objects.requireNonNull(result.getResolvedException()).getMessage())); + } + + @Test + public void rorAuthorityRequestWithEntryID() throws Exception { + checkSingleItemResponse(getClient().perform(get(ROR_AUTHORITY_ENTRIES_URL) + .param("entryID", "03ad39j10")), "University of Pisa", "University of Pisa"); + } + + @Test + public void rorAuthorityRequestWithBadEntryID() throws Exception { + getClient().perform(get(ROR_AUTHORITY_ENTRIES_URL) + .param("entryID", "wrong_entry_id")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.entries", Matchers.hasSize(0))) + .andExpect(jsonPath("$.page.size", Matchers.is(20))) + .andExpect(jsonPath("$.page.number", Matchers.is(0))) + .andExpect(jsonPath("$.page.totalElements", Matchers.is(0))) + .andExpect(jsonPath("$.page.totalPages", Matchers.is(0))); + } + + @Test + public void rorAuthorityRequestWithQueryExact() throws Exception { + checkSingleItemResponse(getClient().perform(get(ROR_AUTHORITY_ENTRIES_URL) + .param("filter", "University of Pisa") + .param("exact", "true")), "University of Pisa", "University of Pisa"); + } + + @Test + public void rorAuthorityRequestWithResponseInLocale() throws Exception { + ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + String defaultLocale = configurationService.getProperty("default.locale"); + configurationService.setProperty("default.locale", "it"); + configurationService.setProperty("ror.authority.stored-name-type", "locale_label"); + + try { + checkSingleItemResponse(getClient().perform(get(ROR_AUTHORITY_ENTRIES_URL) + .param("filter", "University of Pisa") + .param("exact", "true")), "Università di Pisa", "Università di Pisa"); + } finally { + configurationService.setProperty("default.locale", defaultLocale); + configurationService.setProperty("ror.authority.stored-name-type", "en_label"); + } + } + + @Test + public void rorAuthorityRequestWithRorDisplaySelectionType() throws Exception { + ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService(); + String defaultLocale = configurationService.getProperty("default.locale"); + configurationService.setProperty("default.locale", "it"); + configurationService.setProperty("ror.authority.stored-name-type", "ror_display"); + + try { + checkSingleItemResponse(getClient().perform(get(ROR_AUTHORITY_ENTRIES_URL) + .param("filter", "University of Pisa") + .param("exact", "true")), "University of Pisa", "Università di Pisa"); + } finally { + configurationService.setProperty("default.locale", defaultLocale); + configurationService.setProperty("ror.authority.stored-name-type", "en_label"); + } + } + + @Test + public void rorAuthorityRequestWithQuery() throws Exception { + getClient().perform(get(ROR_AUTHORITY_ENTRIES_URL) + .param("filter", "University of Pisa")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.entries", Matchers.hasSize(20))) + .andExpect(jsonPath("$.page.size", Matchers.is(20))) + .andExpect(jsonPath("$.page.number", Matchers.is(0))) + .andExpect(jsonPath("$.page.totalElements", Matchers.is(MOCK_TOTAL_ELEMENTS))) + .andExpect(jsonPath("$.page.totalPages", Matchers.is(MOCK_TOTAL_ELEMENTS / 20 + 1))); + } + + @Test + public void rorAuthorityRequestWithQueryAndPagination() throws Exception { + getClient().perform(get(ROR_AUTHORITY_ENTRIES_URL) + .param("filter", "University of Pisa") + .param("size", "4") + .param("page", "2000")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.entries", Matchers.hasSize(4))) + .andExpect(jsonPath("$.page.size", Matchers.is(4))) + .andExpect(jsonPath("$.page.number", Matchers.is(2000))) + .andExpect(jsonPath("$.page.totalElements", Matchers.is(MOCK_TOTAL_ELEMENTS))) + .andExpect(jsonPath("$.page.totalPages", Matchers.is(MOCK_TOTAL_ELEMENTS / 4 + 1))); + } + + private void checkSingleItemResponse(ResultActions resultActions, String expectedValue, String expectedDisplay) + throws Exception { + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.entries", Matchers.hasSize(1))) + .andExpect(jsonPath("$.page.size", Matchers.is(20))) + .andExpect(jsonPath("$.page.number", Matchers.is(0))) + .andExpect(jsonPath("$.page.totalElements", Matchers.is(1))) + .andExpect(jsonPath("$.page.totalPages", Matchers.is(1))) + .andExpect(jsonPath("$._embedded.entries[0].authority", Matchers.is("03ad39j10"))) + .andExpect(jsonPath("$._embedded.entries[0].display", Matchers.is(expectedDisplay))) + .andExpect(jsonPath("$._embedded.entries[0].value", Matchers.is(expectedValue))) + .andExpect(jsonPath("$._embedded.entries[0].otherInformation.location", + Matchers.is("Pisa, Tuscany, Italy, Europe"))); + } + +} diff --git a/dspace/config/ehcache.xml b/dspace/config/ehcache.xml index 15e6f85ba912..82717c25aefe 100644 --- a/dspace/config/ehcache.xml +++ b/dspace/config/ehcache.xml @@ -82,9 +82,31 @@ + + + 1 + + + + org.dspace.external.ror.CacheLogger + ASYNCHRONOUS + UNORDERED + CREATED + EXPIRED + REMOVED + EVICTED + + + + 1000 + 10 + + + + \ No newline at end of file diff --git a/dspace/config/features/enable-ror.cfg b/dspace/config/features/enable-ror.cfg new file mode 100644 index 000000000000..9e2ca42fb497 --- /dev/null +++ b/dspace/config/features/enable-ror.cfg @@ -0,0 +1,31 @@ +## Register the ROR authority plugin +plugin.named.org.dspace.content.authority.ChoiceAuthority = \ + org.dspace.content.authority.SimpleRORAuthority = SimpleRORAuthority + +choices.plugin.dc.publisher = SimpleRORAuthority +choices.presentation.dc.publisher = lookup +authority.controlled.dc.publisher = true + +ror.api-url = https://api.ror.org/v2/organizations + +### Add the following lines to local.cfg: +#include = features/enable-ror.cfg +#ror.client-id = <> +#ror.authority.stored-name-type = ror_display | en_label | locale_label + +# Notes: +# To obtain a ROR API Client ID, you need to register at: https://ror.org/api-client-id. +# The "ror.authority.stored-name-type" property defines how the authority value is selected +# from the ROR API response. The authority value is then stored to metadata field, e.g. to "dc.publisher" field. +# The ROR API returns multiple names based on the locale and the name type +# the name types are "ror_display", "label", "alias" and "acronym". +# For more information on the name types, see: https://ror.readme.io/docs/ror-data-structure +# Allowed values for the "ror.authority.stored-name-type" property are: +# "ror_display" - authority value is selected from the name of type "ror_display" +# "en_label" - authority value is selected from the name of type "label" with locale "en" +# "locale_label" - authority value is selected from the name of type "label" with locale matching the DSpace locale +# The default "ror.authority.stored-name-type" is "en_label". + +# To enable the SimpleRORAuthority choice working in the Item Submission Page(UI), for the publisher field, +# the following input-type should be set for the "dc.publisher" field, in the submission-forms.xml configuration file: +#onebox diff --git a/dspace/config/spring/api/ror-authority-services.xml b/dspace/config/spring/api/ror-authority-services.xml new file mode 100644 index 000000000000..6e39d8006c47 --- /dev/null +++ b/dspace/config/spring/api/ror-authority-services.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + From c062a85356fdbc9c27abda08761b8e5797d73dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Ko=C5=A1arko?= Date: Fri, 3 Jul 2026 11:05:16 +0200 Subject: [PATCH 2/6] fix potential NPE Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/main/java/org/dspace/external/RorRestConnector.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java b/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java index 5c2a116488a1..bb736388e2c7 100644 --- a/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java +++ b/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java @@ -75,7 +75,7 @@ public Response getByQuery(String query, int page) { } public Response getByID(String rorID) { - if (rorID.matches(ROR_ID_PATTERN)) { + if (rorID != null && rorID.matches(ROR_ID_PATTERN)) { return client.target(apiUrl).path(rorID) .request() .header("Client-Id", clientId) From 139ab2dd23f985e66512fa79910c82bb03e31d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Ko=C5=A1arko?= Date: Fri, 3 Jul 2026 11:11:45 +0200 Subject: [PATCH 3/6] fix possible NPE in getBestMatch --- .../src/main/java/org/dspace/external/RorRestConnector.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java b/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java index bb736388e2c7..7c3b584bfa6f 100644 --- a/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java +++ b/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java @@ -159,11 +159,14 @@ public Choices getMatches(String text, int start, int limit, String locale) { } 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(); + List items = Optional.ofNullable(rorItems.getItems()).orElse(List.of()); if (items.isEmpty()) { return new Choices(false); } From 1be166281bbb2dfdc50d325e1d903a78a69a6c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Ko=C5=A1arko?= Date: Fri, 3 Jul 2026 11:28:20 +0200 Subject: [PATCH 4/6] fix possible NPE and some typos --- .../main/java/org/dspace/external/RorRestConnector.java | 6 +++--- .../main/java/org/dspace/external/model/ror/RorItem.java | 9 +++++---- .../java/org/dspace/external/model/ror/RorItems.java | 3 ++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java b/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java index 7c3b584bfa6f..4066f652af1d 100644 --- a/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java +++ b/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java @@ -166,7 +166,7 @@ public Choices getBestMatch(String text, String locale) { if (response.getStatus() == Response.Status.OK.getStatusCode()) { try (InputStream is = response.readEntity(InputStream.class)) { RorItems rorItems = OBJECT_MAPPER.readValue(is, RorItems.class); - List items = Optional.ofNullable(rorItems.getItems()).orElse(List.of()); + List items = rorItems.getItems(); if (items.isEmpty()) { return new Choices(false); } @@ -224,7 +224,7 @@ private static Choice toChoice(RorItem rorItem, String localeLanguage, StoredNam // 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 aliasses + // 2 - english label from labels, 1 - english label from aliases int enLabelQuality = 0; for (RorItem.Name name : names) { @@ -274,7 +274,7 @@ private static Choice toChoice(RorItem rorItem, String localeLanguage, StoredNam } String value; - // set tha value based on the configuration of the name selection type + // set the value based on the configuration of the name selection type switch (storedNameType) { case ROR_DISPLAY : { value = (rorDisplay != null) ? rorDisplay : label; 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 index 47fa1e4d56c6..fb2921343fe6 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -34,10 +35,10 @@ public RorItem(@JsonProperty("id") String id, @JsonProperty("status") String status, @JsonProperty("types") String[] types) { this.id = id; - this.names = names; - this.locations = locations; + this.names = Optional.ofNullable(names).orElse(List.of()); + this.locations = Optional.ofNullable(locations).orElse(List.of()); this.status = status; - this.types = types; + this.types = Optional.ofNullable(types).orElse(new String[0]); } public String getId() { @@ -71,7 +72,7 @@ public Name(@JsonProperty("lang") String lang, @JsonProperty("types") List types, @JsonProperty("value") String value) { this.lang = lang; - this.types = types; + this.types = Optional.ofNullable(types).orElse(List.of()); this.value = 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 index 3393b4b87fe1..7fdd38489faa 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -32,7 +33,7 @@ public RorItems(@JsonProperty("number_of_results") int noOfResults, ) { this.noOfResults = noOfResults; this.timeTaken = timeTaken; - this.items = items; + this.items = Optional.ofNullable(items).orElse(List.of()); } public int getNoOfResults() { From 490f796d933d196eeaff009337a6ec7bd0ac85c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Ko=C5=A1arko?= Date: Fri, 3 Jul 2026 11:33:23 +0200 Subject: [PATCH 5/6] add license header --- dspace/config/spring/api/ror-authority-services.xml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dspace/config/spring/api/ror-authority-services.xml b/dspace/config/spring/api/ror-authority-services.xml index 6e39d8006c47..3c9945533a7b 100644 --- a/dspace/config/spring/api/ror-authority-services.xml +++ b/dspace/config/spring/api/ror-authority-services.xml @@ -1,4 +1,10 @@ - + Date: Fri, 3 Jul 2026 11:56:08 +0200 Subject: [PATCH 6/6] Don't cache the fallback --- .../src/main/java/org/dspace/external/RorRestConnector.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java b/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java index 4066f652af1d..fb36dfa08349 100644 --- a/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java +++ b/dspace-api/src/main/java/org/dspace/external/RorRestConnector.java @@ -86,7 +86,8 @@ public Response getByID(String rorID) { } } - @Cacheable(cacheNames = "ror-labels", key = "#rorID + '_' + #locale", unless = "#result == null") + @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;