diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/AppConfig.java b/datamanager-app/src/main/java/life/qbic/datamanager/AppConfig.java index c3b0399b5..7049336e1 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/AppConfig.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/AppConfig.java @@ -1,11 +1,11 @@ package life.qbic.datamanager; +import java.util.Objects; import life.qbic.broadcasting.Exchange; import life.qbic.broadcasting.MessageBusSubmission; import life.qbic.domain.concepts.SimpleEventStore; import life.qbic.domain.concepts.TemporaryEventRepository; import life.qbic.identity.api.UserInformationService; -import life.qbic.identity.api.UserPasswordService; import life.qbic.identity.application.communication.EmailService; import life.qbic.identity.application.communication.broadcasting.EventHub; import life.qbic.identity.application.notification.NotificationService; @@ -28,13 +28,13 @@ import life.qbic.projectmanagement.application.AppContextProvider; import life.qbic.projectmanagement.application.OrganisationRepository; import life.qbic.projectmanagement.application.ProjectInformationService; -import life.qbic.projectmanagement.application.concurrent.ElasticScheduler; -import life.qbic.projectmanagement.application.concurrent.VirtualThreadScheduler; import life.qbic.projectmanagement.application.api.SampleCodeService; import life.qbic.projectmanagement.application.authorization.acl.ProjectAccessService; import life.qbic.projectmanagement.application.authorization.authorities.AuthorityService; import life.qbic.projectmanagement.application.batch.BatchRegistrationService; import life.qbic.projectmanagement.application.communication.broadcasting.MessageRouter; +import life.qbic.projectmanagement.application.concurrent.ElasticScheduler; +import life.qbic.projectmanagement.application.concurrent.VirtualThreadScheduler; import life.qbic.projectmanagement.application.experiment.ExperimentInformationService; import life.qbic.projectmanagement.application.measurement.MeasurementLookupService; import life.qbic.projectmanagement.application.policy.BatchRegisteredPolicy; @@ -71,6 +71,8 @@ import life.qbic.projectmanagement.application.sample.qualitycontrol.QualityControlService; import life.qbic.projectmanagement.domain.repository.ProjectRepository; import life.qbic.projectmanagement.infrastructure.organisations.CachedOrganisationRepository; +import life.qbic.projectmanagement.infrastructure.organisations.RorApi; +import life.qbic.projectmanagement.infrastructure.organisations.RorApi.RorApiV2; import org.jobrunr.scheduling.JobScheduler; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; @@ -115,8 +117,19 @@ public IdentityService userRegistrationService( } @Bean - public OrganisationRepository organisationRepository() { - return new CachedOrganisationRepository(); + RorApi rorApi( + @Value("${qbic.external-service.organisation-search.ror.client-id}") String clientId, + @Value("${qbic.external-service.organisation-search.ror.organisation-api-endpoint}") String rorOrganisationEndpoint) { + Objects.requireNonNull(rorOrganisationEndpoint); + Objects.requireNonNull(clientId); + return new RorApiV2(rorOrganisationEndpoint, clientId); + } + + @Bean + public OrganisationRepository organisationRepository( + RorApi rorApi) { + Objects.requireNonNull(rorApi); + return new CachedOrganisationRepository(rorApi); } diff --git a/datamanager-app/src/main/resources/application.properties b/datamanager-app/src/main/resources/application.properties index 5bf2eb54f..e9ecd26a1 100644 --- a/datamanager-app/src/main/resources/application.properties +++ b/datamanager-app/src/main/resources/application.properties @@ -137,6 +137,8 @@ qbic.external-service.person-search.orcid.extended-search-uri=${qbic.external-se qbic.external-service.person-search.orcid.scope=/read-public qbic.external-service.person-search.orcid.grant-type=client_credentials qbic.external-service.person-search.orcid.issuer=${ORCID_SEARCH_ISSUER_URL:https://orcid.org} +qbic.external-service.organisation-search.ror.organisation-api-endpoint=${ROR_ORGANISATION_ENDPOINT:https://api.ror.org/v2/organizations/} +qbic.external-service.organisation-search.ror.client-id=${ROR_CLIENT_ID} ############################################################################### ################### ActiveMQ Artemis ########################################## diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/organisations/CachedOrganisationRepository.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/organisations/CachedOrganisationRepository.java index d5aaf4e93..3305ae7b0 100644 --- a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/organisations/CachedOrganisationRepository.java +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/organisations/CachedOrganisationRepository.java @@ -1,25 +1,14 @@ package life.qbic.projectmanagement.infrastructure.organisations; -import static life.qbic.logging.service.LoggerFactory.logger; - -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpClient.Redirect; -import java.net.http.HttpClient.Version; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse.BodyHandlers; -import java.time.Duration; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.regex.MatchResult; import java.util.regex.Pattern; -import life.qbic.logging.api.Logger; import life.qbic.projectmanagement.application.OrganisationRepository; import life.qbic.projectmanagement.domain.Organisation; +import life.qbic.projectmanagement.infrastructure.organisations.RorApi.RorEntry; import org.springframework.context.annotation.Profile; /** @@ -38,23 +27,22 @@ @Profile("production") public class CachedOrganisationRepository implements OrganisationRepository { - private static final Logger log = logger(CachedOrganisationRepository.class); private static final int DEFAULT_CACHE_SIZE = 50; - private static final String ROR_API_URL = "https://api.ror.org/v1/organizations/%s"; private static final String ROR_ID_PATTERN = "0[a-z|0-9]{6}[0-9]{2}$"; private final Map iriToOrganisation = new HashMap<>(); private final int configuredCacheSize; - private boolean cacheUsedForLastRequest = false; + private final RorApi rorApi; - - public CachedOrganisationRepository(int cacheSize) { - this.configuredCacheSize = cacheSize; + public CachedOrganisationRepository(RorApi rorApi) { + this.rorApi = Objects.requireNonNull(rorApi); + this.configuredCacheSize = DEFAULT_CACHE_SIZE; } - public CachedOrganisationRepository() { - this.configuredCacheSize = DEFAULT_CACHE_SIZE; + public CachedOrganisationRepository(int cacheSize, RorApi rorApi) { + this.configuredCacheSize = cacheSize; + this.rorApi = Objects.requireNonNull(rorApi); } private static Optional extractRorId(String text) { @@ -78,44 +66,26 @@ private Optional lookupCache(String iri) { } private Optional lookupROR(String iri) { - return extractRorId(iri).map(this::findOrganisationInROR).or(Optional::empty); + return extractRorId(iri) + .flatMap(this::findOrganisationInROR); } - private Organisation findOrganisationInROR(String rorId) { - try { - HttpClient client = HttpClient.newBuilder().version(Version.HTTP_2) - .followRedirects(Redirect.NORMAL).connectTimeout( - Duration.ofSeconds(10)).build(); - HttpRequest rorQuery = HttpRequest.newBuilder().uri(URI.create(ROR_API_URL.formatted(rorId))) - .header("Content-Type", "application/json").GET().build(); - var result = client.send(rorQuery, BodyHandlers.ofString()); - //If a valid RoRId was provided but the ID does not exist we fail - if (result.statusCode() != 200) { - log.warn( - "Provided Organisation ROR id: %s was not found via API call to %s".formatted(rorId, - ROR_API_URL)); - return null; - } - RORentry rorEntry = new ObjectMapper().configure( - DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .readValue(result.body(), RORentry.class); - updateCache(rorEntry); + private Optional findOrganisationInROR(String rorId) { + Optional rorEntry = rorApi.find(rorId); + rorEntry.ifPresent(entry -> { + updateCache(entry); cacheUsedForLastRequest = false; - return new Organisation(rorEntry.getId(), rorEntry.getName()); - } catch (IOException | InterruptedException e) { - log.error("Finding ROR entry failed for organisation: %s".formatted(rorId), e); - /* Clean up whatever needs to be handled before interrupting */ - Thread.currentThread().interrupt(); - return null; - } + }); + return rorEntry + .map(entry -> new Organisation(entry.getId(), entry.getDisplayedName())); } - private void updateCache(RORentry rorEntry) { + private void updateCache(RorEntry rorEntry) { if (iriToOrganisation.size() == configuredCacheSize) { String firstKey = iriToOrganisation.keySet().stream().toList().get(0); iriToOrganisation.remove(firstKey); } - iriToOrganisation.put(rorEntry.getId(), rorEntry.getName()); + iriToOrganisation.put(rorEntry.getId(), rorEntry.getDisplayedName()); } public int cacheEntries() { diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/organisations/RORentry.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/organisations/RORentry.java deleted file mode 100644 index d76ba474e..000000000 --- a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/organisations/RORentry.java +++ /dev/null @@ -1,41 +0,0 @@ -package life.qbic.projectmanagement.infrastructure.organisations; - -import com.fasterxml.jackson.annotation.JsonProperty; - -/** - * Research Organisation Registry Entry - * - *

A ROR entry that is returned by the ROR API.

- * - * @since 1.0.0 - */ -public class RORentry { - - @JsonProperty("id") - String id; - - @JsonProperty("name") - String name; - - public String getName() { - return this.name; - } - - public void setName(String name) { - if (name == null) { - name = ""; - } - this.name = name.trim(); - } - - public String getId() { - return id; - } - - public void setId(String id) { - if (id == null) { - id = ""; - } - this.id = id; - } -} diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/organisations/RorApi.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/organisations/RorApi.java new file mode 100644 index 000000000..e19b12eba --- /dev/null +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/organisations/RorApi.java @@ -0,0 +1,258 @@ +package life.qbic.projectmanagement.infrastructure.organisations; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpClient.Redirect; +import java.net.http.HttpClient.Version; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.time.Duration; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Optional; +import life.qbic.logging.api.Logger; +import life.qbic.logging.service.LoggerFactory; +import org.eclipse.jetty.http.HttpStatus; + +/** + * API for research organisation registry. ROR (https://ror.org/) + */ +public interface RorApi { + + /** + * Research Organisation Registry Entry + * + *

A ROR entry that is returned by the ROR API.

+ * + * @since 1.0.0 + */ + interface RorEntry { + + /** + * + * @return the complete ROR identifier + */ + String getId(); + + /** + * + * @return the displayed name for the organization. This is not guaranteed to be in a specific + * language. + */ + String getDisplayedName(); + + } + + /** + * Searches for an entry in the research organisation registry. If no identifier is provided or + * the provided identifier is empty, {@link Optional#empty()} is returned. + * + * @param rorId the ror identifier to search for e.g. 00v34f693 for QBiC + * @return an optional RorEntry + * @since 1.13.0 + */ + Optional find(String rorId); + + final class RorApiV2 implements RorApi { + + private static final Logger log = LoggerFactory.logger(RorApiV2.class); + private final URI organisationApiEndpoint; + private final String apiClientId; + + public RorApiV2(String organisationApiEndpoint, String apiClientId) { + this.organisationApiEndpoint = URI.create(organisationApiEndpoint); + if (!this.organisationApiEndpoint.isAbsolute()) { + throw new IllegalArgumentException("The provided api uri is not absolute."); + } + if (!this.organisationApiEndpoint.getScheme().equals("https")) { + throw new IllegalArgumentException("No HTTPS endpoint provided."); + } + this.apiClientId = Objects.requireNonNull(apiClientId); + } + + public static final class RorEntryV2 implements RorEntry { + + @JsonProperty("id") + String id; + + @JsonProperty("names") + List names; + + public static class OrganisationName { + + @JsonProperty("lang") + String language; + + @JsonProperty("types") + List types; + + @JsonProperty("value") + String value; + + public String getLanguage() { + return language; + } + + public List getTypes() { + return types; + } + + public String getValue() { + return value; + } + } + + public String getId() { + return id; + } + + @Override + public String getDisplayedName() { + return names.stream().filter(name -> name.getTypes().contains("ror_display")).findFirst() + .map(OrganisationName::getValue) + .orElseThrow(); + } + } + + @Override + public Optional find(String rorId) { + if (rorId == null || rorId.isBlank()) { + log.warn("API called without ror identifier. Skipping call and returning empty."); + return Optional.empty(); + } + + var request = createHttpRequest(organisationApiEndpoint.resolve(rorId)); + + RorResponse result; + try { + result = sendRequest(request); + } catch (RorRequestException e) { + String errorMessage = "Could not request information from " + request.uri() + "."; + if (e.getHttpStatusCode().isPresent()) { + errorMessage += " Failed with http status code " + e.getHttpStatusCode().orElseThrow(); + } + log.error(errorMessage, e); + return Optional.empty(); + } + + Optional rorEntry = result.optionalBody() + .flatMap(this::parseJson); + + if (rorEntry.isEmpty()) { + log.warn( + "No organisation with identifier %s was found on %s.".formatted(rorId, request.uri())); + } + + return rorEntry; + } + + private Optional parseJson(String json) { + try { + RorEntry rorEntry = new ObjectMapper().configure( + DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .readValue(json, RorEntryV2.class); + return Optional.of(rorEntry); + } catch (JsonProcessingException e) { + log.error("Could not parse response from ROR.", e); + return Optional.empty(); + } + } + + + /** + * Needs to be handled + */ + private static class RorRequestException extends Exception { + + private final Integer httpStatusCode; + + + public RorRequestException(Throwable cause) { + super(cause); + httpStatusCode = null; + } + + public RorRequestException(String message, int httpStatusCode) { + super(message); + this.httpStatusCode = httpStatusCode; + } + + public Optional getHttpStatusCode() { + return Optional.ofNullable(httpStatusCode); + } + } + + private record RorResponse(String body) { + + public Optional optionalBody() { + return Optional.ofNullable(body); + } + + @Override + public String body() { + if (Objects.isNull(body)) { + throw new NoSuchElementException( + "No body is present. Please use RorResponse#optionalBody instead"); + } + return body; + } + + static RorResponse empty() { + return new RorResponse(null); + } + + static RorResponse of(String body) { + Objects.requireNonNull(body); + return new RorResponse(body); + } + } + + private RorResponse sendRequest(HttpRequest request) throws RorRequestException { + HttpResponse result; + try (HttpClient client = HttpClient.newBuilder().version(Version.HTTP_2) + .followRedirects(Redirect.NORMAL).connectTimeout( + Duration.ofSeconds(10)).build();) { + + result = client.send(request, BodyHandlers.ofString()); + //If a valid RoRId was provided but the ID does not exist we fail + } catch (IOException e) { + throw new RorRequestException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RorRequestException(e); + } + + if (result.statusCode() == HttpStatus.NOT_FOUND_404) { + log.warn("Organisation not found for " + request.uri()); + return RorResponse.empty(); + } + //If a valid RoRId was provided but the ID does not exist we fail + if (result.statusCode() != 200) { + throw new RorRequestException( + "Unexpected HTTP status code returned from retrieving an organization.", + result.statusCode()); + } else { + return RorResponse.of(result.body()); + } + } + + private HttpRequest createHttpRequest(URI requestUri) { + var queryBuilder = HttpRequest.newBuilder() + .uri(requestUri) + .header("Content-Type", "application/json") + .header("Client-Id", apiClientId) + .GET(); + + return queryBuilder.build(); + } + } + + +} diff --git a/project-management-infrastructure/src/test/groovy/life/qbic/projectmanagement/infrastructure/CachedOrganisationRepositorySpec.groovy b/project-management-infrastructure/src/test/groovy/life/qbic/projectmanagement/infrastructure/CachedOrganisationRepositorySpec.groovy index 8e5cf812f..335ba74ab 100644 --- a/project-management-infrastructure/src/test/groovy/life/qbic/projectmanagement/infrastructure/CachedOrganisationRepositorySpec.groovy +++ b/project-management-infrastructure/src/test/groovy/life/qbic/projectmanagement/infrastructure/CachedOrganisationRepositorySpec.groovy @@ -1,13 +1,45 @@ package life.qbic.projectmanagement.infrastructure import life.qbic.projectmanagement.infrastructure.organisations.CachedOrganisationRepository +import life.qbic.projectmanagement.infrastructure.organisations.RorApi import spock.lang.Shared import spock.lang.Specification class CachedOrganisationRepositorySpec extends Specification { + @Shared + def universityEntry = new RorApi.RorEntry() { + + @Override + String getId() { + return "https://ror.org/03a1kwz48" + } + + @Override + String getDisplayedName() { + return "University of Tübingen" + } + } + @Shared + def qbicEntry = new RorApi.RorEntry() { + + @Override + String getId() { + return "https://ror.org/00v34f693" + } + + @Override + String getDisplayedName() { + return "Quantitative Biology Center" + } + } @Shared - CachedOrganisationRepository cachedOrganisationRepository = new CachedOrganisationRepository(); + RorApi rorApi = Stub() { + find("00v34f693") >> Optional.of(qbicEntry) + find("03a1kwz48") >> Optional.of(universityEntry) + }; + + CachedOrganisationRepository cachedOrganisationRepository = new CachedOrganisationRepository(rorApi); def setup() { cachedOrganisationRepository.resolve("https://ror.org/03a1kwz48") @@ -16,7 +48,7 @@ class CachedOrganisationRepositorySpec extends Specification { def "Given a ROR IRI with valid ROR id, resolve the correct organisation"() { given: - def cachedRepoInstance = new CachedOrganisationRepository() + def cachedRepoInstance = new CachedOrganisationRepository(rorApi) when: def result = cachedRepoInstance.resolve(rorIri) @@ -27,7 +59,6 @@ class CachedOrganisationRepositorySpec extends Specification { !cachedRepoInstance.cacheUsedForLastRequest() - where: rorIri | organisationName "https://ror.org/03a1kwz48" | "University of Tübingen" @@ -60,10 +91,10 @@ class CachedOrganisationRepositorySpec extends Specification { def "Given an unknown ROR IRI, return an empty result"() { given: - def cachedRepoInstance = new CachedOrganisationRepository() + def cachedRepoInstance = new CachedOrganisationRepository(rorApi) when: - def result = cachedRepoInstance.resolve( "https://ror.org/00v3223") + def result = cachedRepoInstance.resolve("https://ror.org/00v3223") then: result.isEmpty() @@ -71,7 +102,7 @@ class CachedOrganisationRepositorySpec extends Specification { def "Given a full cache, free a slot and write the new entry"() { given: - def singularRepoInstance = new CachedOrganisationRepository(1) + def singularRepoInstance = new CachedOrganisationRepository(1, rorApi) singularRepoInstance.resolve("https://ror.org/03a1kwz48") and: // we override the cache entry since the size is 1