diff --git a/dspace-api/src/main/java/org/dspace/external/AbstractRestConnector.java b/dspace-api/src/main/java/org/dspace/external/AbstractRestConnector.java
new file mode 100644
index 000000000000..b657c122128c
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/external/AbstractRestConnector.java
@@ -0,0 +1,200 @@
+/**
+ * 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.BufferedInputStream;
+import java.io.IOException;
+
+import org.apache.http.HttpEntity;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.dspace.external.exception.ExternalDataException;
+import org.dspace.external.exception.ExternalDataNotFoundException;
+import org.dspace.external.exception.ExternalDataRestClientException;
+
+/**
+ * Abstract REST connector that can make API requests with Closeable HTTP client.
+ * The client can be set by the user, e.g. a Test can mock a client to return a response from disk.
+ * For usage examples see {@link LobidGNDRestConnectorTest},
+ * {@link WikimediaRestConnectorTest},
+ * {@link GeonamesRestConnectorTest}
+ *
+ * TODO: Apply changes from DSpace#9821 (Enable proxy for outgoing connections)
+ *
+ * @author Kim Shepherd
+ */
+public abstract class AbstractRestConnector {
+
+ /**
+ * Injectable http client for test mocking and other custom usage
+ */
+ private CloseableHttpClient httpClient = null;
+
+ /**
+ * REST connector/source name, useful for logging and conditional handling by other services
+ */
+ protected String name;
+
+ /**
+ * Base API url, set in spring configuration
+ */
+ protected String url;
+
+ /**
+ * Logger
+ */
+ private final Logger log = LogManager.getLogger();
+
+ public AbstractRestConnector() {
+
+ }
+
+ /**
+ * Constructor, accepting a URL
+ * @param url base URL of API
+ */
+ public AbstractRestConnector(String url) {
+ this.url = url;
+ }
+
+ /**
+ * Get http client
+ * @return http client
+ */
+ public HttpClient getHttpClient() {
+ return httpClient;
+ }
+
+ /**
+ * Set http client
+ * @param httpClient http client to use instead of default
+ */
+ public void setHttpClient(CloseableHttpClient httpClient) {
+ this.httpClient = httpClient;
+ }
+
+ /**
+ * Get API base URL
+ * @return API base URL
+ */
+ public String getUrl() {
+ return url;
+ }
+
+ /**
+ * Set API base URL
+ * @param url API base URL
+ */
+ public void setUrl(String url) {
+ this.url = url;
+ }
+
+ /**
+ * Get REST connector name
+ * @return name
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Set REST connector name
+ * @param name name of connector (e.g. wikimedia, geonames)
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * Get a response from a remote REST API using closeable HTTP client, and read the body entity
+ * into a string for return (not a stream).
+ *
+ * @param requestUrl the full request URL, including parameters
+ * @return parsed response string
+ * @throws ExternalDataException if a non-200 code was returned or another error was encountered
+ */
+ public String get(String requestUrl) throws ExternalDataException {
+ log.debug("Using request URL={}, connector={}", requestUrl, name);
+ try (CloseableHttpClient closeableHttpClient = createHttpClient()) {
+ HttpGet httpGet = new HttpGet(requestUrl);
+ CloseableHttpResponse response = closeableHttpClient.execute(httpGet);
+ // Check response
+ if (200 == response.getStatusLine().getStatusCode()) {
+ // Handle successful response
+ HttpEntity entity = response.getEntity();
+ if (entity == null) {
+ log.debug("Null entity for 200 OK response from {} API, status={}",
+ name, response.getStatusLine());
+ throw new ExternalDataRestClientException("External lookup responded with 200 but body was null. "
+ + "connector=" + name + "url=" + requestUrl);
+ }
+ return readResultEntityToString(entity);
+ } else if (404 == response.getStatusLine().getStatusCode()) {
+ throw new ExternalDataNotFoundException("External lookup responded with 404 Not Found. connector="
+ + name + "url=" + requestUrl);
+ }
+ else {
+ // Handle unsuccessful response
+ log.error("Got unsuccessful response from {} API: code={}, reason={}, url={}",
+ name, response.getStatusLine().getStatusCode(), response.getStatusLine().getReasonPhrase(),
+ this.url);
+ }
+ // If we reached here, something went wrong
+ log.error("Unexpected error handling response for url {}, connector={}", requestUrl, name);
+ throw new ExternalDataRestClientException("External lookup failed. connector="
+ + name + "url=" + requestUrl);
+ } catch (IOException e) {
+ log.error("Unexpected error performing http request for url {}, connector={}", requestUrl, name);
+ throw new ExternalDataRestClientException(e);
+ }
+ }
+
+ /**
+ * Given an http entity from API response, parse to a string and return so
+ * the http client can be closed safely after any input streams are closed
+ * @param entity the response HTTP entity
+ * @return a string containing the JSON response
+ * @throws IOException
+ */
+ private String readResultEntityToString(HttpEntity entity) throws IOException {
+ String result = null;
+ log.debug("Got successful (200 OK) response from {} API, content type={}, length={}",
+ name, entity.getContentType(), entity.getContentLength());
+ // Read the content input stream into a string, using try-with-resources to ensure stream is closed
+ try (final BufferedInputStream in = new BufferedInputStream(entity.getContent())) {
+ byte[] contents = new byte[1024];
+ int bytesRead = 0;
+ StringBuilder content = new StringBuilder();
+ while ((bytesRead = in.read(contents)) != -1) {
+ content.append(new String(contents, 0, bytesRead));
+ }
+ result = content.toString();
+ }
+ return result;
+ }
+
+ /**
+ * Create HTTP client. If the member client is null, a new CloseableHttpClient is built, otherwise
+ * this.httpClient is used. This allows tests to mock an http client, and allows for other custom client usage
+ *
+ * @return http client to use in actual request
+ */
+ private CloseableHttpClient createHttpClient() {
+ if (this.httpClient != null) {
+ return this.httpClient;
+ } else {
+ return HttpClientBuilder.create().build();
+ }
+ }
+
+}
diff --git a/dspace-api/src/main/java/org/dspace/external/ExternalDataObjectBuilder.java b/dspace-api/src/main/java/org/dspace/external/ExternalDataObjectBuilder.java
new file mode 100644
index 000000000000..8e5f7c1f9b51
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/external/ExternalDataObjectBuilder.java
@@ -0,0 +1,228 @@
+/**
+ * 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 org.dspace.content.dto.MetadataValueDTO;
+import org.dspace.external.model.ExternalDataObject;
+
+public class ExternalDataObjectBuilder {
+
+ private ExternalDataObject externalDataObject;
+
+ public ExternalDataObjectBuilder() {
+ externalDataObject = new ExternalDataObject();
+ }
+
+ public ExternalDataObjectBuilder create() {
+ externalDataObject = new ExternalDataObject();
+ return this;
+ }
+
+ public ExternalDataObjectBuilder create(String source) {
+ externalDataObject = new ExternalDataObject(source);
+ return addMetadata("dc", "source", null, null, source);
+ }
+
+ public ExternalDataObject build() {
+ return externalDataObject;
+ }
+
+ public ExternalDataObjectBuilder withId(String id) {
+ this.externalDataObject.setId(id);
+ return addMetadata("dc", "identifier", "uri", null, id);
+ }
+
+ public ExternalDataObjectBuilder withSource(String source) {
+ this.externalDataObject.setSource(source);
+ return addMetadata("dc", "source", null, null, source);
+ }
+
+ public ExternalDataObjectBuilder withValue(String value) {
+ this.externalDataObject.setValue(value);
+ return this;
+ }
+
+ public ExternalDataObjectBuilder withDisplayValue(String displayValue) {
+ this.externalDataObject.setDisplayValue(displayValue);
+ return this;
+ }
+
+ public ExternalDataObjectBuilder withLastModified(String lastModified) {
+ return addMetadata("gnd", "date", "modified", null, lastModified);
+ }
+
+ public ExternalDataObjectBuilder withType(String... values) {
+ return addMetadata("gnd", "type", null, null, values);
+ }
+
+ public ExternalDataObjectBuilder withTitle(String... values) {
+ return addMetadata("dc", "title", null, null, values);
+ }
+
+ public ExternalDataObjectBuilder withGeographicAreaCodeId(String... values) {
+ return addMetadata("gnd", "geographicAreaCode", "id", null, values);
+ }
+
+ public ExternalDataObjectBuilder withGeographicAreaCodeLabel(String... values) {
+ return addMetadata("gnd", "geographicAreaCode", "label", null, values);
+ }
+
+ public ExternalDataObjectBuilder withBio(String... values) {
+ return addMetadata("gnd", "biographicalOrHistoricalInformation", null, null, values);
+ }
+
+ public ExternalDataObjectBuilder withHomepage(String... values) {
+ return addMetadata("gnd", "homepage", null, null, values);
+ }
+
+ public ExternalDataObjectBuilder withVariantNames(String... values) {
+ return addMetadata("gnd", "variantName", null, null, 2, values);
+ }
+
+ public ExternalDataObjectBuilder withDepiction(String... values) {
+ return addMetadata("gnd", "depiction", "thumbnail", null, values);
+ }
+
+ public ExternalDataObjectBuilder withDepictionLicense(String... values) {
+ return addMetadata("gnd", "depiction", "license", null, values);
+ }
+
+ public ExternalDataObjectBuilder withSameAs(String key, String... values) {
+ return addMetadata("gnd", "sameAs", key, null, values);
+ }
+
+ public ExternalDataObjectBuilder withSubjectCategory(String... values) {
+ return addMetadata("gnd", "subjectCategory", null, null, values);
+ }
+
+ public ExternalDataObjectBuilder withEventDate(String... values) {
+ return addMetadata("gnd", "dateOfConferenceOrEvent", null, null, values);
+ }
+
+ public ExternalDataObjectBuilder withEventPlace(String... values) {
+ return addMetadata("gnd", "placeOfConferenceOrEvent", null, null, values);
+ }
+
+ public ExternalDataObjectBuilder withHierarchicalSuperior(String... values) {
+ return addMetadata("gnd", "hierarchicalSuperiorOfTheConferenceOrEvent",
+ null, null, values);
+ }
+
+ public ExternalDataObjectBuilder withContributorAuthor(String... values) {
+ return addMetadata("dc", "contributor", "author",
+ null, values);
+ }
+
+ public ExternalDataObjectBuilder withComposer(String... values) {
+ return addMetadata("dc", "contributor", "author",
+ null, values);
+ }
+
+ public ExternalDataObjectBuilder withDefinition(String... values) {
+ return addMetadata("dc", "description", null,
+ null, values);
+ }
+
+ public ExternalDataObjectBuilder withBroaderTerm(String... values) {
+ return addMetadata("gnd", "broaderTermGeneric", "label",
+ null, values);
+ }
+
+ public ExternalDataObjectBuilder withOrgAddressLocality(String... values) {
+ return addMetadata("organization", "address", "addressLocality", null, values);
+ }
+
+ public ExternalDataObjectBuilder withOrgAddressCountry(String... values) {
+ return addMetadata("organization", "address", "addressCountry", null, values);
+ }
+
+ public ExternalDataObjectBuilder withOrgName(String... values) {
+ return addMetadata("organization", "legalName", null, null, values);
+ }
+
+ public ExternalDataObjectBuilder withDeprecatedUri(String... values) {
+ return addMetadata("gnd", "deprecatedUri", null, null, values);
+ }
+
+ public ExternalDataObjectBuilder withOrgISNI(String... values) {
+ return addMetadata("organization", "identifier", "isni", null, values);
+ }
+
+ public ExternalDataObjectBuilder withPersonISNI(String... values) {
+ return addMetadata("person", "identifier", "isni", null, values);
+ }
+
+ public ExternalDataObjectBuilder withORCID(String... values) {
+ return addMetadata("person", "identifier", "orcid", null, values);
+ }
+
+ public ExternalDataObjectBuilder withGivenName(String... values) {
+ return addMetadata("person", "givenName", null, null, values);
+ }
+
+ public ExternalDataObjectBuilder withFamilyName(String... values) {
+ return addMetadata("person", "familyName", null, null, values);
+ }
+
+ public ExternalDataObjectBuilder withFamilyNamePrefix(String... values) {
+ return addMetadata("gnd", "familyName", "prefix", null, values);
+ }
+
+ public ExternalDataObjectBuilder withPersonNameVariant(String... values) {
+ return addMetadata("person", "name", "variant", null, values);
+ }
+
+ public ExternalDataObjectBuilder withPersonAffiliation(String... values) {
+ return addMetadata("person", "affiliation", "name", null, values);
+ }
+
+ public ExternalDataObjectBuilder withGeospatialPoint(String... values) {
+ return addMetadata("dcterms", "spatial", null, null, values);
+ }
+
+ public ExternalDataObjectBuilder withLatitude(String... values) {
+ return addMetadata("gnd", "spatial", "latitude", null, values);
+ }
+
+ public ExternalDataObjectBuilder withLongitude(String... values) {
+ return addMetadata("gnd", "spatial", "longitude", null, values);
+ }
+
+ public ExternalDataObjectBuilder withBoundingBox(String... values) {
+ return addMetadata("gnd", "spatial", "bbox", null, values);
+ }
+
+ public ExternalDataObjectBuilder withMetadataValueDTO(MetadataValueDTO value) {
+ this.externalDataObject.addMetadata(value);
+ return this;
+ }
+
+ private ExternalDataObjectBuilder addMetadata(String schema, String element, String qualifier, String language, String... values) {
+ for (String v : values) {
+ this.externalDataObject.addMetadata(
+ new MetadataValueDTO(schema, element, qualifier,
+ language, v));
+ }
+ return this;
+ }
+
+ private ExternalDataObjectBuilder addMetadata(String schema, String element, String qualifier, String language, int limit, String... values) {
+ int i = 0;
+ for (String v : values) {
+ if (i < limit) {
+ this.externalDataObject.addMetadata(
+ new MetadataValueDTO(schema, element, qualifier,
+ language, v));
+ } else {
+ break;
+ }
+ i++;
+ }
+ return this;
+ }
+}
diff --git a/dspace-api/src/main/java/org/dspace/external/GNDUtils.java b/dspace-api/src/main/java/org/dspace/external/GNDUtils.java
new file mode 100644
index 000000000000..79a1b491171f
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/external/GNDUtils.java
@@ -0,0 +1,129 @@
+/**
+ * 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.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.dspace.services.ConfigurationService;
+import org.dspace.services.factory.DSpaceServicesFactory;
+
+/**
+ * General static utilities for use with GND and LOBID API code:
+ * Formatting URIs and identifiers, constructing exemplar objects for testing correct object parsing.
+ * See {@link LobidGNDRestConnector}
+ *
+ * @author Kim Shepherd
+ */
+public class GNDUtils {
+
+ /**
+ * Initialise configuration service
+ */
+ private static final ConfigurationService configurationService =
+ DSpaceServicesFactory.getInstance().getConfigurationService();
+ /**
+ * Initialise logger
+ */
+ private static final Logger log = LogManager.getLogger(GNDUtils.class);
+
+ public GNDUtils() {}
+
+ /**
+ * Given a partial identifier, full identifier, or JSON request URL, return the official
+ * LOBID object URI in https://lobid.org/gnd/{identifier}.json format, for retrieving the object
+ * by identifier
+ *
+ * @param identifier Partial or full identifier
+ * @return full GND URI
+ */
+ public static String formatObjectURI(String identifier) throws IllegalArgumentException {
+ if (null == identifier) {
+ throw new IllegalArgumentException("Null GND identifier supplied to formatURI()");
+ }
+ String partialIdentifier = null;
+ // Firstly, try to extract the partial identifier
+ try {
+ partialIdentifier = extractIdentifier(identifier);
+ } catch (IllegalArgumentException e) {
+ // If we caught this exception, we will continue
+ log.debug("Could not extract a partial identifier from non-null string, " +
+ "will simply prepend the URL prefix to the original value (" + identifier + ")");
+ partialIdentifier = identifier;
+ }
+
+ // Prefix the identifier with the configured URL prefix and return
+ String urlPrefix = configurationService.getProperty("gnd.api.url",
+ "https://lobid.org/gnd/");
+ return urlPrefix + partialIdentifier + ".json";
+ }
+
+ /**
+ * Given a partial identifier, full identifier, or JSON request URL, return the official
+ * GND URI in https://d-nb.info/gnd/{identifier} format
+ *
+ * @param identifier Partial or full identifier
+ * @return full GND URI
+ */
+ public static String formatURI(String identifier) throws IllegalArgumentException {
+ if (null == identifier) {
+ throw new IllegalArgumentException("Null GND identifier supplied to formatURI()");
+ }
+ String partialIdentifier = null;
+ // Firstly, try to extract the partial identifier
+ try {
+ partialIdentifier = extractIdentifier(identifier);
+ } catch (IllegalArgumentException e) {
+ // If we caught this exception, we will continue
+ log.debug("Could not extract a partial identifier from non-null string, " +
+ "will simply prepend the URL prefix to the original value (" + identifier + ")");
+ partialIdentifier = identifier;
+ }
+
+ // Prefix the identifier with the configured URL prefix and return
+ String urlPrefix = configurationService.getProperty("gnd.uri.prefix",
+ "https://d-nb.info/gnd/");
+ return urlPrefix + partialIdentifier;
+ }
+
+ /**
+ * Extract and return a GND identifier from a full URI or other format. This can then be used to construct
+ * other URIs, URLs, paths, log messages, labels, and so on.
+ *
+ * @param identifier the input value from which to parse and extract a partial identifier
+ * e.g. https://d-nb.info/gnd/4074335-4
+ * @return partial identifier eg. 4074335-4
+ */
+ public static String extractIdentifier(String identifier) {
+ // Throw a hard error if this parameter is null
+ if (null == identifier) {
+ throw new IllegalArgumentException("Null GND identifier supplied to formatIdentifer()");
+ }
+ // Partial/basic identifier: 4074335-4
+ // GND URI: https://d-nb.info/gnd/4074335-4
+ // API request URL: http://lobid.org/gnd/4074335-4.json
+
+ // Read the regular expression from configuration, which we'll use to extract the identifier from either
+ // of the expected paths above
+ String regex = configurationService.getProperty("gnd.identifier.regex",
+ "^https?://[^/]*/gnd/([^.]*).*$");
+ // Attempt to match the identifier
+ Pattern p = Pattern.compile(regex);
+ Matcher m = p.matcher(identifier);
+ if (m.matches()) {
+ // Return the matched identifier portion, eg. 4074335-4
+ return m.group(1);
+ }
+ // If we reached this line, we did not find a valid match and we should throw an error
+ throw new IllegalArgumentException("Supplied string does not match regex. input="
+ + identifier + ", regex=" + regex);
+ }
+
+}
diff --git a/dspace-api/src/main/java/org/dspace/external/GeonamesGetCacheLogger.java b/dspace-api/src/main/java/org/dspace/external/GeonamesGetCacheLogger.java
new file mode 100644
index 000000000000..3a89086ae9c2
--- /dev/null
+++ b/dspace-api/src/main/java/org/dspace/external/GeonamesGetCacheLogger.java
@@ -0,0 +1,31 @@
+/**
+ * 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 org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.ehcache.event.CacheEvent;
+import org.ehcache.event.CacheEventListener;
+
+/**
+ * This is a EHCache listener responsible for logging Geonames "get by ID" cache events.
+ * @see dspace/config/ehcache.xml
+ *
+ * @author Kim Shepherd
+ */
+public class GeonamesGetCacheLogger implements CacheEventListener