Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 rorOrganisationEndpoint,
@Value("${qbic.external-service.organisation-search.ror.organisation-api-endpoint}") String clientId) {
Objects.requireNonNull(rorOrganisationEndpoint);
Objects.requireNonNull(clientId);
return new RorApiV2(rorOrganisationEndpoint, clientId);
}

@Bean
public OrganisationRepository organisationRepository(
RorApi rorApi) {
Objects.requireNonNull(rorApi);
return new CachedOrganisationRepository(rorApi);
}


Expand Down
2 changes: 2 additions & 0 deletions datamanager-app/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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 ##########################################
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/**
Expand All @@ -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<String, String> 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<String> extractRorId(String text) {
Expand All @@ -78,44 +66,26 @@ private Optional<Organisation> lookupCache(String iri) {
}

private Optional<Organisation> 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<Organisation> findOrganisationInROR(String rorId) {
Optional<RorEntry> 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() {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package life.qbic.projectmanagement.infrastructure.organisations;

import com.fasterxml.jackson.annotation.JsonProperty;
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.List;
import java.util.Objects;
import java.util.Optional;
import life.qbic.logging.api.Logger;
import life.qbic.logging.service.LoggerFactory;

/**
* API for research organisation registry. ROR (https://ror.org/)
*/
public interface RorApi {

/**
* <b>Research Organisation Registry Entry</b>
*
* <p>A ROR entry that is returned by the ROR API.</p>
*
* @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();

}

Optional<RorEntry> 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);
this.apiClientId = Objects.requireNonNull(apiClientId);
}

public static final class RorEntryV2 implements RorEntry {

@JsonProperty("id")
String id;

@JsonProperty("names")
List<OrganisationName> names;

public static class OrganisationName {

@JsonProperty("lang")
String language;

@JsonProperty("types")
List<String> types;

@JsonProperty("value")
String value;

public String getLanguage() {
return language;
}

public List<String> getTypes() {
return types;
}

public String getValue() {
return value;
}
}

public String getId() {
return id;
}

@Override
public String getDisplayedName() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method signature does not indicate that an exception is thrown. Exceptions cause side effects and need to be taken into account by the client that calls the method. Why not making the method return value Optional in the first place?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should never be an exception as there should always be a display name in the v2 ROR API.
If there is not then an exception is appropriate. I would not annotate the method as I do not think we should ever expect that to happen.

return names.stream().filter(name -> name.getTypes().contains("ror_display")).findFirst()
.map(OrganisationName::getValue)
.orElseThrow();
}
}

@Override
public Optional<RorEntry> find(String rorId) {

try (HttpClient client = HttpClient.newBuilder().version(Version.HTTP_2)
.followRedirects(Redirect.NORMAL).connectTimeout(
Duration.ofSeconds(10)).build();) {

URI requestUri = organisationApiEndpoint.resolve(rorId);

var queryBuilder = HttpRequest.newBuilder()
.uri(requestUri)
.header("Content-Type", "application/json")
.header("Client-Id", apiClientId)
.GET();

var rorQuery = queryBuilder.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() == 404) {
log.warn(
"Provided Organisation ROR id: %s was not found via API call to %s".formatted(rorId,
requestUri));
return Optional.empty();
}
//If a valid RoRId was provided but the ID does not exist we fail
else if (result.statusCode() != 200) {
log.warn(
("Unexpected error retrieving an organization with ROR id %s. API call to %s").formatted(
rorId,
requestUri));
return Optional.empty();
}
RorEntry rorEntry = new ObjectMapper().configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readValue(result.body(), RorEntryV2.class);
return Optional.of(rorEntry);
} 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 Optional.empty();
}
}
}


}
Loading
Loading