diff --git a/dspace-api/src/main/java/org/dspace/content/MetadataValueServiceImpl.java b/dspace-api/src/main/java/org/dspace/content/MetadataValueServiceImpl.java index 97f0c2ccf4ca..849e065cb653 100644 --- a/dspace-api/src/main/java/org/dspace/content/MetadataValueServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/MetadataValueServiceImpl.java @@ -114,6 +114,12 @@ public Iterator findByValueLike(Context context, String value) th return metadataValueDAO.findByValueLike(context, value); } + @Override + public List findByAuthorityAndLanguage(Context context, String authority, String language) + throws SQLException { + return metadataValueDAO.findByAuthorityAndLanguage(context, authority, language); + } + @Override public void deleteByMetadataField(Context context, MetadataField metadataField) throws SQLException { metadataValueDAO.deleteByMetadataField(context, metadataField); diff --git a/dspace-api/src/main/java/org/dspace/content/authority/SimpleORCIDAuthority.java b/dspace-api/src/main/java/org/dspace/content/authority/SimpleORCIDAuthority.java new file mode 100644 index 000000000000..dc249c5ecfa6 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/content/authority/SimpleORCIDAuthority.java @@ -0,0 +1,234 @@ +/** + * 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 java.sql.SQLException; +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.dspace.content.MetadataValue; +import org.dspace.content.factory.ContentServiceFactory; +import org.dspace.content.service.MetadataValueService; +import org.dspace.core.Context; +import org.dspace.external.CachingOrcidRestConnector; +import org.dspace.external.provider.orcid.xml.ExpandedSearchConverter; +import org.dspace.utils.DSpace; +import org.dspace.web.ContextUtil; + + +/** + * ChoiceAuthority using the ORCID API. + * It uses the orcid as the authority value and thus is simpler to use then the * SolrAuthority. + */ +public class SimpleORCIDAuthority implements ChoiceAuthority { + + private static final Logger log = LogManager.getLogger(SimpleORCIDAuthority.class); + + private String pluginInstanceName; + private final CachingOrcidRestConnector orcidRestConnector = new DSpace().getServiceManager().getServiceByName( + "CachingOrcidRestConnector", CachingOrcidRestConnector.class); + private final MetadataValueService metadataValueService = ContentServiceFactory.getInstance() + .getMetadataValueService(); + private static final int maxResults = 100; + + /** + * 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) { + log.debug("getMatches: " + text + ", start: " + start + ", limit: " + limit + ", locale: " + locale); + if (text == null || text.trim().isEmpty()) { + return new Choices(true); + } + + start = Math.max(start, 0); + if (limit < 1 || limit > maxResults) { + limit = maxResults; + } + + ExpandedSearchConverter.Results search = orcidRestConnector.search(text, start, limit); + List choices = search.results().stream() + .map(this::toChoice) + .collect(Collectors.toList()); + + + int confidence = !search.isOk() ? Choices.CF_FAILED : + choices.isEmpty() ? Choices.CF_NOTFOUND : + choices.size() == 1 ? Choices.CF_UNCERTAIN + : Choices.CF_AMBIGUOUS; + int total = search.numFound().intValue(); + return new Choices(choices.toArray(new Choice[0]), start, total, + confidence, total > (start + limit)); + } + + /** + * 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) { + log.debug("getBestMatch: " + text); + Choices matches = getMatches(text, 0, 1, locale); + if (matches.values.length != 0 && !matches.values[0].value.equalsIgnoreCase(text)) { + // novalue + matches = new Choices(false); + } + return matches; + } + + /** + * 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) { + log.debug("getLabel: " + key); + String label = orcidRestConnector.getLabel(key); + if (label != null) { + return label; + } + + return resolveLocalLabel(key, locale); + } + + private String resolveLocalLabel(String key, String locale) { + Context requestContext = ContextUtil.obtainCurrentRequestContext(); + Context context = requestContext; + + try { + if (context == null) { + context = createReadOnlyContext(); + } + if (context == null) { + return key; + } + return queryLabel(context, key, locale); + } catch (Exception e) { + log.error("Error resolving local label for authority key '{}'", key, e); + return key; + } + // We intentionally do NOT call context.abort() on a locally created Context. + // During CLI operations (e.g. reindexing), the new Context shares the Hibernate + // session (thread-local) with the caller's Context. Calling abort() triggers + // closeDBConnection() -> rollback(), which kills the shared transaction and causes + // Hibernate to clear the persistence context — detaching ALL managed entities. + // This leads to LazyInitializationException when the caller later accesses + // lazy-loaded properties (e.g. DSpaceObject.handles). + // Since we only performed read operations, no cleanup is needed. + // The session/transaction lifecycle is managed by the caller's Context. + } + + Context createReadOnlyContext() { + try { + return new Context(Context.Mode.READ_ONLY); + } catch (Exception | ExceptionInInitializerError e) { + log.error("Failed to create read-only context", e); + return null; + } + } + + private String queryLabel(Context context, String key, String locale) throws SQLException { + String normalizedLocale = StringUtils.isBlank(locale) ? null : locale; + + List results = metadataValueService + .findByAuthorityAndLanguage(context, key, normalizedLocale); + if (!results.isEmpty()) { + return results.get(0).getValue(); + } + + if (normalizedLocale != null) { + List fallback = metadataValueService + .findByAuthorityAndLanguage(context, key, null); + if (!fallback.isEmpty()) { + return fallback.get(0).getValue(); + } + } + + return key; + } + } + + /** + * 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; + } + + private Choice toChoice(ExpandedSearchConverter.Result result) { + Choice c = new Choice(result.authority(), result.value(), result.label()); + //add orcid to extras so it's shown + c.extras.put("orcid", result.authority()); + // add the value to extra information only if it is present + //in dspace-angular the extras are keys for translation form.other-information. + result.creditName().ifPresent(val -> c.extras.put("credit-name", val)); + result.otherNames().ifPresent(val -> c.extras.put("other-names", val)); + result.institutionNames().ifPresent(val -> c.extras.put("institution", val)); + + return c; + } +} diff --git a/dspace-api/src/main/java/org/dspace/content/dao/MetadataValueDAO.java b/dspace-api/src/main/java/org/dspace/content/dao/MetadataValueDAO.java index 6a09cfdd8e00..603868c39bf7 100644 --- a/dspace-api/src/main/java/org/dspace/content/dao/MetadataValueDAO.java +++ b/dspace-api/src/main/java/org/dspace/content/dao/MetadataValueDAO.java @@ -34,6 +34,9 @@ public Iterator findItemValuesByFieldAndValue(Context context, public Iterator findByValueLike(Context context, String value) throws SQLException; + public List findByAuthorityAndLanguage(Context context, String authority, String language) + throws SQLException; + public void deleteByMetadataField(Context context, MetadataField metadataField) throws SQLException; public MetadataValue getMinimum(Context context, int metadataFieldId) diff --git a/dspace-api/src/main/java/org/dspace/content/dao/impl/MetadataValueDAOImpl.java b/dspace-api/src/main/java/org/dspace/content/dao/impl/MetadataValueDAOImpl.java index dc624c98c6aa..fb57ffc795f9 100644 --- a/dspace-api/src/main/java/org/dspace/content/dao/impl/MetadataValueDAOImpl.java +++ b/dspace-api/src/main/java/org/dspace/content/dao/impl/MetadataValueDAOImpl.java @@ -72,6 +72,30 @@ public Iterator findByValueLike(Context context, String value) th return iterate(query); } + @Override + public List findByAuthorityAndLanguage(Context context, String authority, String language) + throws SQLException { + StringBuilder queryString = new StringBuilder(); + queryString.append("SELECT m FROM MetadataValue m WHERE m.authority = :authority"); + + if (language != null) { + queryString.append(" AND m.language = :language"); + } + + queryString.append(" ORDER BY m.place ASC"); + + Query query = createQuery(context, queryString.toString()); + query.setParameter("authority", authority); + + if (language != null) { + query.setParameter("language", language); + } + + @SuppressWarnings("unchecked") + List results = query.getResultList(); + return results; + } + @Override public void deleteByMetadataField(Context context, MetadataField metadataField) throws SQLException { String queryString = "delete from MetadataValue where metadataField= :metadataField"; diff --git a/dspace-api/src/main/java/org/dspace/content/service/MetadataValueService.java b/dspace-api/src/main/java/org/dspace/content/service/MetadataValueService.java index 1cf26e37f160..fb316c483096 100644 --- a/dspace-api/src/main/java/org/dspace/content/service/MetadataValueService.java +++ b/dspace-api/src/main/java/org/dspace/content/service/MetadataValueService.java @@ -98,6 +98,18 @@ public void update(Context context, MetadataValue metadataValue, boolean modifyP public Iterator findByValueLike(Context context, String value) throws SQLException; + /** + * Retrieves matching MetadataValues for a given authority and language. + * + * @param context dspace context + * @param authority The authority value that must match + * @param language The language that must match (can be null) + * @return the matching MetadataValues + * @throws SQLException if database error + */ + public List findByAuthorityAndLanguage(Context context, String authority, String language) + throws SQLException; + public void deleteByMetadataField(Context context, MetadataField metadataField) throws SQLException; /** diff --git a/dspace-api/src/main/java/org/dspace/core/HibernateDBConnection.java b/dspace-api/src/main/java/org/dspace/core/HibernateDBConnection.java index 806930d0364a..6caca8fa6193 100644 --- a/dspace-api/src/main/java/org/dspace/core/HibernateDBConnection.java +++ b/dspace-api/src/main/java/org/dspace/core/HibernateDBConnection.java @@ -295,7 +295,13 @@ public void uncacheEntity(E entity) throws SQLExcep } else if (entity instanceof Bundle) { Bundle bundle = (Bundle) entity; - if (Hibernate.isInitialized(bundle.getBitstreams())) { + // Bundle.getBitstreams() creates a defensive copy via new ArrayList<>(bitstreams) + // which iterates the Hibernate proxy, triggering lazy loading unconditionally. + // Unlike Item.getBundles() which returns the raw proxy, we cannot safely call + // getBitstreams() when the bundle is detached from the session. + // Guard with session.contains(): if the bundle is still managed, + // lazy loading will work; if detached (e.g. after session.clear()), we skip. + if (getSession().contains(bundle)) { for (Bitstream bitstream : Utils.emptyIfNull(bundle.getBitstreams())) { uncacheEntity(bitstream); } diff --git a/dspace-api/src/main/java/org/dspace/discovery/SolrServiceImpl.java b/dspace-api/src/main/java/org/dspace/discovery/SolrServiceImpl.java index 8ecd39d8254b..be1bb677d908 100644 --- a/dspace-api/src/main/java/org/dspace/discovery/SolrServiceImpl.java +++ b/dspace-api/src/main/java/org/dspace/discovery/SolrServiceImpl.java @@ -347,7 +347,13 @@ public void updateIndex(Context context, boolean force, String type) { final Iterator indexableObjects = indexableObjectService.findAll(context); while (indexableObjects.hasNext()) { final IndexableObject indexableObject = indexableObjects.next(); - indexContent(context, indexableObject, force); + try { + indexContent(context, indexableObject, force); + } catch (RuntimeException e) { + log.error("Failed to index object {} (type={}): {}", + indexableObject.getUniqueIndexID(), indexableObject.getType(), + e.getMessage(), e); + } context.uncacheEntity(indexableObject.getIndexedObject()); indexObject++; if ((indexObject % 100) == 0 && indexableObjectService instanceof ItemIndexFactory) { diff --git a/dspace-api/src/main/java/org/dspace/external/CachingOrcidRestConnector.java b/dspace-api/src/main/java/org/dspace/external/CachingOrcidRestConnector.java new file mode 100644 index 000000000000..e34767a25063 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/external/CachingOrcidRestConnector.java @@ -0,0 +1,222 @@ +/** + * 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.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Optional; +import java.util.regex.Pattern; + +import org.apache.commons.lang.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.dspace.external.provider.orcid.xml.ExpandedSearchConverter; +import org.dspace.services.ConfigurationService; +import org.dspace.utils.DSpace; +import org.json.JSONObject; +import org.springframework.cache.annotation.Cacheable; + +/** + * A different implementation of the communication with the ORCID API. + * The API returns no-cache headers, we use @Cacheable to cache the labels (id->name) for some time. + * Originally the idea was to reuse the OrcidRestConnector, but in the end that just wraps apache http client. + */ +public class CachingOrcidRestConnector { + private static final Logger log = LogManager.getLogger(CachingOrcidRestConnector.class); + + private String apiURL; + // Access tokens are long-lived ~ 20years, don't bother with refreshing + private volatile String _accessToken; + private final ExpandedSearchConverter converter = new ExpandedSearchConverter(); + + private static final Pattern p = Pattern.compile("^\\p{Alpha}+", Pattern.UNICODE_CHARACTER_CLASS); + private static final String edismaxParams = "&defType=edismax&qf=" + + URLEncoder.encode( "family-name^4.0 credit-name^3.0 other-names^2.0 text", StandardCharsets.UTF_8); + + private final HttpClient httpClient = HttpClient + .newBuilder() + .connectTimeout( Duration.ofSeconds(5)) + .build(); + + /* + * We basically need to obtain the access token only once, but there is no guarantee this will succeed. The + * failure shouldn't be fatal, so we'll try again next time. + */ + private Optional init() { + if (_accessToken == null) { + synchronized (CachingOrcidRestConnector.class) { + if (_accessToken == null) { + log.info("Initializing Orcid connector"); + ConfigurationService configurationService = new DSpace().getConfigurationService(); + String clientSecret = configurationService.getProperty("orcid.application-client-secret"); + String clientId = configurationService.getProperty("orcid.application-client-id"); + String OAUTHUrl = configurationService.getProperty("orcid.token-url"); + + try { + _accessToken = getAccessToken(clientSecret, clientId, OAUTHUrl); + } catch (Exception e) { + log.error("Error during initialization of the Orcid connector", e); + } + } + } + } + return Optional.ofNullable(_accessToken); + } + + /** + * Set the URL of the ORCID API + * @param apiURL + */ + public void setApiURL(String apiURL) { + this.apiURL = apiURL; + } + + /** + * Search the ORCID API + * + * The query is passed to the ORCID API as is, except when it contains just 'unicode letters'. + * In that case, we try to be smart and turn it into edismax query with wildcard. + * + * @param query - the search query + * @param start - initial offset when paging results + * @param limit - maximum number of results to return + * @return the results + */ + public ExpandedSearchConverter.Results search(String query, int start, int limit) { + String extra; + // if query contains just 'unicode letters'; try to be smart and turn it into edismax query with wildcard + if (p.matcher(query).matches()) { + query += " || " + query + "*"; + extra = edismaxParams; + } else { + extra = ""; + } + final String searchPath = String.format("expanded-search?q=%s&start=%s&rows=%s%s", URLEncoder.encode(query, + StandardCharsets.UTF_8), start, limit, extra); + + return init().map(token -> { + try (InputStream inputStream = httpGet(searchPath, token)) { + return converter.convert(inputStream); + } catch (IOException e) { + log.error("Error during search", e); + return ExpandedSearchConverter.ERROR; + } + }).orElse(ExpandedSearchConverter.ERROR); + } + + /** + * Get the label for an ORCID, ideally the name of the person. + * + * Null is: + * - either an error -> won't be cached, + * - or it means no result, which'd be odd provided we get here with a valid orcid -> not caching should be ok + * + * @param orcid the id you are looking for + * @return the label or null in case nothing found/error + */ + @Cacheable(cacheNames = "orcid-labels", unless = "#result == null") + public String getLabel(String orcid) { + log.debug("getLabel: " + orcid); + // in theory, we could use orcid.org/v3.0//personal-details, but didn't want to write another converter + ExpandedSearchConverter.Results search = search("orcid:" + orcid, 0, 1); + if (search.isOk() && search.numFound() > 0) { + return search.results().get(0).label(); + } + return null; + } + + protected String getAccessToken(String clientSecret, String clientId, String OAUTHUrl) { + if (StringUtils.isNotBlank(clientSecret) + && StringUtils.isNotBlank(clientId) + && StringUtils.isNotBlank(OAUTHUrl)) { + String authenticationParameters = + String.format("client_id=%s&client_secret=%s&scope=/read-public&grant_type=client_credentials", + clientId, clientSecret); + + HttpRequest request = HttpRequest.newBuilder() + .uri(java.net.URI.create(OAUTHUrl)) + .POST(HttpRequest.BodyPublishers.ofString(authenticationParameters)) + .timeout(Duration.ofSeconds(5)) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .build(); + + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (isSuccess(response)) { + JSONObject responseObject = new JSONObject(response.body()); + return responseObject.getString("access_token"); + } else { + log.error("Error during initialization of the Orcid connector, status code: " + + response.statusCode()); + throw new RuntimeException("Error during initialization of the Orcid connector, status code: " + + response.statusCode()); + } + } catch (IOException | InterruptedException e) { + log.error("Error during initialization of the Orcid connector", e); + throw new RuntimeException(e); + } + } else { + log.error("Missing configuration for Orcid connector"); + throw new RuntimeException("Missing configuration for Orcid connector"); + } + } + + private InputStream httpGet(String path, String accessToken) throws IOException { + String trimmedPath = path.replaceFirst("^/+", "").replaceFirst("/+$", ""); + + String fullPath = apiURL + '/' + trimmedPath; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(fullPath)) + .timeout(Duration.ofSeconds(5)) + .header("Content-Type", "application/vnd.orcid+xml") + .header("Authorization", "Bearer " + accessToken) + .build(); + + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + if (isSuccess(response)) { + return response.body(); + } else { + log.error("Error in rest connector for path: " + fullPath + ", status code: " + response.statusCode()); + throw new UnexpectedStatusException("Error in rest connector for path: " + + fullPath + ", status code: " + response.statusCode()); + } + } catch (UnexpectedStatusException e) { + throw e; + } catch (IOException | InterruptedException e) { + log.error("Error in rest connector for path: " + fullPath, e); + throw new RuntimeException(e); + } + } + + private boolean isSuccess(HttpResponse response) { + return response.statusCode() >= 200 && response.statusCode() < 300; + } + + private static class UnexpectedStatusException extends IOException { + public UnexpectedStatusException(String message) { + super(message); + } + } + + //Just for testing + protected void forceAccessToken(String accessToken) { + synchronized (CachingOrcidRestConnector.class) { + this._accessToken = accessToken; + } + } +} diff --git a/dspace-api/src/main/java/org/dspace/external/provider/orcid/xml/CacheLogger.java b/dspace-api/src/main/java/org/dspace/external/provider/orcid/xml/CacheLogger.java new file mode 100644 index 000000000000..061bd4a6d425 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/external/provider/orcid/xml/CacheLogger.java @@ -0,0 +1,25 @@ +/** + * 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.provider.orcid.xml; + +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 cache events + */ +public class CacheLogger implements CacheEventListener { + private static final Logger log = LogManager.getLogger(CacheLogger.class); + @Override + public void onEvent(CacheEvent event) { + log.debug("ORCID Cache Event Type: {} | Key: {} ", + event.getType(), event.getKey()); + } +} diff --git a/dspace-api/src/main/java/org/dspace/external/provider/orcid/xml/ExpandedSearchConverter.java b/dspace-api/src/main/java/org/dspace/external/provider/orcid/xml/ExpandedSearchConverter.java new file mode 100644 index 000000000000..f5ffe879d998 --- /dev/null +++ b/dspace-api/src/main/java/org/dspace/external/provider/orcid/xml/ExpandedSearchConverter.java @@ -0,0 +1,199 @@ +/** + * 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.provider.orcid.xml; + +import static org.apache.commons.lang.StringUtils.isBlank; +import static org.apache.commons.lang.StringUtils.isNotBlank; + +import java.io.InputStream; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.apache.logging.log4j.Logger; +import org.orcid.jaxb.model.v3.release.search.expanded.ExpandedResult; +import org.orcid.jaxb.model.v3.release.search.expanded.ExpandedSearch; +import org.xml.sax.SAXException; + +/** + * Convert the XML response from the ORCID API to a list of Results + * The conversion here is sort of a layer between the Choice class and the ORCID classes + */ +public class ExpandedSearchConverter extends Converter { + + public static final ExpandedSearchConverter.Results ERROR = + new ExpandedSearchConverter.Results(new ArrayList<>(), 0L, false); + + private static final Logger log = org.apache.logging.log4j.LogManager.getLogger(ExpandedSearchConverter.class); + + @Override + public ExpandedSearchConverter.Results convert(InputStream inputStream) { + try { + ExpandedSearch search = (ExpandedSearch) unmarshall(inputStream, ExpandedSearch.class); + long numFound = search.getNumFound(); + return new Results(search.getResults().stream() + .filter(Objects::nonNull) + .filter(result -> isNotBlank(result.getOrcidId())) + .map(ExpandedSearchConverter.Result::new) + .collect(Collectors.toList()), numFound); + } catch (SAXException | URISyntaxException e) { + log.error(e); + } + return ERROR; + } + + + /** + * Keeps the results and their total number + */ + public static final class Results { + private final List results; + private final Long numFound; + + private final boolean ok; + + Results(List results, Long numFound) { + this(results, numFound, true); + } + + Results(List results, Long numFound, boolean ok) { + this.results = results; + this.numFound = numFound; + this.ok = ok; + } + + + /** + * The results + * @return the results as List + */ + public List results() { + return results; + } + + /** + * The total number of results + * @return the number of results + */ + public Long numFound() { + return numFound; + } + + /** + * Whether there were any issues + * @return false if there were issues + */ + public boolean isOk() { + return ok; + } + + @Override + public String toString() { + return "Results[" + + "results=" + results + ", " + + "numFound=" + numFound + ']'; + } + + } + + /** + * Represents a single result + * Taking care of potential null/empty values + */ + public static final class Result { + private final String authority; + private final String value; + private final String label; + private final String creditName; + private final String[] otherNames; + private final String[] institutionNames; + + Result(ExpandedResult result) { + if (isBlank(result.getOrcidId())) { + throw new IllegalArgumentException("OrcidId is required"); + } + final String last = isNotBlank(result.getFamilyNames()) ? result.getFamilyNames() : ""; + final String first = isNotBlank(result.getGivenNames()) ? result.getGivenNames() : ""; + final String maybeComma = isNotBlank(last) && isNotBlank(first) ? ", " : ""; + String displayName = String.format("%s%s%s", last, maybeComma, first); + displayName = isNotBlank(displayName) ? displayName : result.getOrcidId(); + + this.authority = result.getOrcidId(); + this.value = displayName; + this.label = displayName; + + this.creditName = result.getCreditName(); + this.otherNames = result.getOtherNames(); + this.institutionNames = result.getInstitutionNames(); + } + + /** + * The authority value + * @return orcid + */ + public String authority() { + return authority; + } + + /** + * The value to store + * @return the value + */ + public String value() { + return value; + } + + /** + * The label to display + * @return the label + */ + public String label() { + return label; + } + + /** + * Optional extra info - credit name + * @return the credit name + */ + public Optional creditName() { + return Optional.ofNullable(creditName); + } + + /** + * Optional extra info - other names + * @return other names + */ + public Optional otherNames() { + return Optional.ofNullable(otherNames).map(names -> String.join(" | ", names)); + } + + /** + * Optional extra info - institution names + * @return institution names + */ + public Optional institutionNames() { + //joining with newline doesn't seem to matter for ui + return Optional.ofNullable(institutionNames) .map(names -> String.join(" | ", names)); + } + + @Override + public String toString() { + return "Result[" + + "authority=" + authority + ", " + + "value=" + value + ", " + + "label=" + label + ", " + + "creditNames=" + creditName + ", " + + "otherNames=" + Arrays.toString(otherNames) + ", " + + "institutionNames=" + Arrays.toString(institutionNames) + ']'; + } + } +} \ No newline at end of file diff --git a/dspace-api/src/test/data/dspaceFolder/config/spring/api/orcid-authority-services.xml b/dspace-api/src/test/data/dspaceFolder/config/spring/api/orcid-authority-services.xml index 3e38055b678a..74b83610d090 100644 --- a/dspace-api/src/test/data/dspaceFolder/config/spring/api/orcid-authority-services.xml +++ b/dspace-api/src/test/data/dspaceFolder/config/spring/api/orcid-authority-services.xml @@ -17,7 +17,11 @@ - + + + + + diff --git a/dspace-api/src/test/java/org/dspace/external/CachingOrcidRestConnectorTest.java b/dspace-api/src/test/java/org/dspace/external/CachingOrcidRestConnectorTest.java new file mode 100644 index 000000000000..bdb051601cb8 --- /dev/null +++ b/dspace-api/src/test/java/org/dspace/external/CachingOrcidRestConnectorTest.java @@ -0,0 +1,169 @@ +/** + * 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import org.dspace.AbstractDSpaceTest; +import org.dspace.external.provider.orcid.xml.ExpandedSearchConverter; +import org.dspace.utils.DSpace; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.cache.Cache; +import org.springframework.cache.jcache.JCacheCacheManager; + +public class CachingOrcidRestConnectorTest extends AbstractDSpaceTest { + + //This token should be valid for 20 years + private static final String sandboxToken = "4bed1e13-7792-4129-9f07-aaf7b88ba88f"; + + private static final String orcid = "0000-0002-9150-2529"; + private static final String expectedLabel = "Connor, John"; + + private CachingOrcidRestConnector sut; + + @Before + public void setup() { + sut = new CachingOrcidRestConnector(); + } + + @Test(expected = RuntimeException.class) + public void getAccessToken_badUrl() { + String accessToken = sut.getAccessToken("secret","id", "http://example.com"); + assertNull("Expecting accessToken to be null", accessToken); + } + + @Test(expected = RuntimeException.class) + public void getAccessToken_badParams() { + //expect an exception to be thrown + sut.getAccessToken(null, null, null); + } + + @Test(expected = RuntimeException.class) + public void getAccessToken() { + String accessToken = sut.getAccessToken("DEAD", "BEEF", "https://sandbox.orcid.org/oauth/token"); + assertNotNull("Expecting accessToken to be not null", accessToken); + } + + @Test + public void getLabel() { + sut = Mockito.spy(sut); + sut.setApiURL("https://pub.sandbox.orcid.org/v3.0"); + //Mock the CachingOrcidRestConnector so that getAccessToken returns sandboxToken + doReturn(sandboxToken).when(sut).getAccessToken(Mockito.anyString(), Mockito.anyString(), Mockito.anyString()); + + String label = sut.getLabel(orcid); + assertEquals(expectedLabel, label); + } + @Test + public void search() { + sut = Mockito.spy(sut); + sut.setApiURL("https://pub.sandbox.orcid.org/v3.0"); + //Mock the CachingOrcidRestConnector so that getAccessToken returns sandboxToken + doReturn(sandboxToken).when(sut).getAccessToken(Mockito.anyString(), Mockito.anyString(), Mockito.anyString()); + + ExpandedSearchConverter.Results search = sut.search("joh", 0, 1); + //Should match all Johns also, because edismax with wildcard + assertTrue(search.numFound() > 1000); + } + + @Test + public void search_fail() { + sut = Mockito.spy(sut); + sut.setApiURL("https://pub.sandbox.orcid.org/v3.0"); + //Mock the CachingOrcidRestConnector so that getAccessToken returns and invalid token + doReturn("FAKE").when(sut).getAccessToken(Mockito.anyString(), Mockito.anyString(), + Mockito.anyString()); + + ExpandedSearchConverter.Results search = sut.search("joh", 0, 1); + + assertFalse(search.isOk()); + + //Further calls fail too, token is stored + search = sut.search("joh", 0, 1); + assertFalse(search.isOk()); + + verify(sut, times(1)).getAccessToken(Mockito.anyString(), Mockito.anyString(), Mockito.anyString()); + + + } + + @Test + public void testCachable() { + CachingOrcidRestConnector c = new DSpace().getServiceManager().getServiceByName( + "CachingOrcidRestConnector", CachingOrcidRestConnector.class); + + Cache cache = prepareCache(); + + assertNull(cache.get(orcid)); + + /* + I have issues trying to mock/spy when the class a spring bean modified by cglib + doReturn(sandboxToken).when(c).getAccessToken(Mockito.anyString(), Mockito.anyString(), Mockito.anyString()); + verify(c, times(1)).getLabel(orcid); + */ + + c.setApiURL("https://pub.sandbox.orcid.org/v3.0"); + c.forceAccessToken(sandboxToken); + + String r1 = c.getLabel(orcid); + assertEquals(expectedLabel, r1); + String r2 = c.getLabel(orcid); + assertEquals(expectedLabel, r2); + //get the orcid-labels cache and verify that the label is there + assertEquals(expectedLabel, cache.get(orcid).get()); + } + + @Test + public void testCacheableWithError() { + CachingOrcidRestConnector c = new DSpace().getServiceManager().getServiceByName( + "CachingOrcidRestConnector", CachingOrcidRestConnector.class); + + Cache cache = prepareCache(); + assertNull(cache.get(orcid)); + + //skip init + c.forceAccessToken(sandboxToken); + //set bad ApiURL to provoke an error + c.setApiURL("https://api.sandbox.orcid.org/"); + String r1 = c.getLabel(orcid); + //on error, getLabel should return null + assertNull(r1); + //the cache should not contain a value for this id + assertNull(cache.get(orcid)); + + //fix the error + c.setApiURL("https://pub.sandbox.orcid.org/v3.0"); + // the error flipped the initialized flag, this reset it + c.forceAccessToken(sandboxToken); + String r2 = c.getLabel(orcid); + assertEquals(expectedLabel, r2); + //the cache should now contain a value for this id + assertEquals(expectedLabel, cache.get(orcid).get()); + } + + private Cache prepareCache() { + //get the cacheManager from the serviceManager + JCacheCacheManager cacheManager = new DSpace().getServiceManager().getServiceByName("cacheManager", + JCacheCacheManager.class); + + Cache cache = cacheManager.getCache("orcid-labels"); + //each test should have a clean cache + cache.clear(); + return cache; + } + +} diff --git a/dspace/config/dspace.cfg b/dspace/config/dspace.cfg index c782659605bf..2aa0e7b11e05 100644 --- a/dspace/config/dspace.cfg +++ b/dspace/config/dspace.cfg @@ -1748,3 +1748,4 @@ include = ${module_dir}/external-providers.cfg include = ${module_dir}/ldn.cfg include = ${module_dir}/contentreport.cfg include = ${module_dir}/matomo.cfg +include = features/enable-orcid.cfg diff --git a/dspace/config/ehcache.xml b/dspace/config/ehcache.xml index 41508a5fa826..15e6f85ba912 100644 --- a/dspace/config/ehcache.xml +++ b/dspace/config/ehcache.xml @@ -61,9 +61,30 @@ 4 + + + 1 + + + + org.dspace.external.provider.orcid.xml.CacheLogger + ASYNCHRONOUS + UNORDERED + CREATED + EXPIRED + REMOVED + EVICTED + + + + 3000 + 10 + + + \ No newline at end of file diff --git a/dspace/config/features/enable-orcid.cfg b/dspace/config/features/enable-orcid.cfg new file mode 100644 index 000000000000..722d44f37c53 --- /dev/null +++ b/dspace/config/features/enable-orcid.cfg @@ -0,0 +1,24 @@ +## ORCID authority (https://wiki.lyrasis.org/display/DSDOC7x/ORCID+Authority) is bit cumbersome to use +plugin.named.org.dspace.content.authority.ChoiceAuthority = \ + org.dspace.content.authority.SimpleORCIDAuthority = SimpleORCIDAuthority +choices.plugin.dc.contributor.author = SimpleORCIDAuthority +choices.presentation.dc.contributor.author = authorLookup +authority.controlled.dc.contributor.author = true +# https://wiki.lyrasis.org/display/DSPACE/Authority+Control+of+Metadata+Values#AuthorityControlofMetadataValues-SettingMinimumConfidence +authority.minconfidence.dc.contributor.author = unset + +# These ORCID settings are now required for ORCID Authority +orcid.domain-url = https://orcid.org +# You can use either the Public API or Member API +orcid.api-url = https://pub.orcid.org/v3.0 + +### add the following lines to local.cfg +#include=features/enable-orcid.cfg + +# You do NOT need to pay for a Member API ID to use ORCID Authority. +# Instead, you just need a Public API ID from a free ORCID account. +# https://info.orcid.org/documentation/features/public-api/ +#orcid.application-client-id = +#orcid.application-client-secret = +# +#event.dispatcher.default.consumers = authority, versioning, discovery, eperson diff --git a/dspace/config/registries/datacite.xml b/dspace/config/registries/datacite.xml new file mode 100644 index 000000000000..e9c47907b77d --- /dev/null +++ b/dspace/config/registries/datacite.xml @@ -0,0 +1,232 @@ + + + + datacite + http://datacite.org/schema/kernel-4 + + + datacite + relation + IsCitedBy + IsCitedBy + + + datacite + relation + IsReferencedBy + IsReferencedBy + + + datacite + relation + IsSupplementTo + IsSupplementTo + + + datacite + relation + Cites + Cites + + + datacite + relation + References + References + + + datacite + relation + IsSupplementedBy + IsSupplementedBy + + + datacite + relation + IsContinuedBy + IsContinuedBy + + + datacite + relation + Continues + Continues + + + datacite + relation + IsDescribedBy + IsDescribedBy + + + datacite + relation + Describes + Describes + + + datacite + relation + HasMetadata + HasMetadata + + + datacite + relation + IsMetadataFor + IsMetadataFor + + + datacite + relation + HasVersion + HasVersion + + + datacite + relation + IsVersionOf + IsVersionOf + + + datacite + relation + IsNewVersionOf + IsNewVersionOf + + + datacite + relation + IsPreviousVersionOf + IsPreviousVersionOf + + + datacite + relation + IsPartOf + IsPartOf + + + datacite + relation + HasPart + HasPart + + + datacite + relation + IsPublishedIn + IsPublishedIn + + + datacite + relation + IsDocumentedBy + IsDocumentedBy + + + datacite + relation + Documents + Documents + + + datacite + relation + IsCompiledBy + IsCompiledBy + + + datacite + relation + Compiles + Compiles + + + datacite + relation + IsVariantFormOf + IsVariantFormOf + + + datacite + relation + IsOriginalFormOf + IsOriginalFormOf + + + datacite + relation + IsIdenticalTo + IsIdenticalTo + + + datacite + relation + IsReviewedBy + IsReviewedBy + + + datacite + relation + Reviews + Reviews + + + datacite + relation + IsDerivedFrom + IsDerivedFrom + + + datacite + relation + IsSourceOf + IsSourceOf + + + datacite + relation + IsRequiredBy + IsRequiredBy + + + datacite + relation + Requires + Requires + + + datacite + relation + IsObsoletedBy + IsObsoletedBy + + + datacite + relation + Obsoletes + Obsoletes + + + datacite + relation + IsCollectedBy + IsCollectedBy + + + datacite + relation + Collects + Collects + + + + + datacite + alternateIdentifier + An identifier other than the primary Identifier applied to the resource being registered. This may be any alphanumeric string which is unique within its domain of issue. May be used for local identifiers, a serial number of an instrument or an inventory number. The AlternateIdentifier should be an additional identifier for the same instance of the resource (i.e., same location, same file). + + + + diff --git a/dspace/config/registries/dublin-core-types.xml b/dspace/config/registries/dublin-core-types.xml index 803b9bc0c7a4..02edaae1aa44 100644 --- a/dspace/config/registries/dublin-core-types.xml +++ b/dspace/config/registries/dublin-core-types.xml @@ -283,6 +283,15 @@ Uniform Resource Identifier + + + dc + identifier + orcid + ORCID identifier imported from OBD + + + dc diff --git a/dspace/config/spring/api/orcid-authority-services.xml b/dspace/config/spring/api/orcid-authority-services.xml index c641b1cf88fb..17457847665c 100644 --- a/dspace/config/spring/api/orcid-authority-services.xml +++ b/dspace/config/spring/api/orcid-authority-services.xml @@ -24,11 +24,15 @@ --> - + + + + + - +