diff --git a/pom.xml b/pom.xml index a535ed1..b6d30e1 100644 --- a/pom.xml +++ b/pom.xml @@ -56,6 +56,18 @@ org.springframework.boot spring-boot-starter-actuator + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + org.springframework.boot diff --git a/src/main/java/eu/dissco/core/datacitepublisher/configuration/WebClientConfig.java b/src/main/java/eu/dissco/core/datacitepublisher/configuration/WebClientConfig.java index 090f853..f11b7b8 100644 --- a/src/main/java/eu/dissco/core/datacitepublisher/configuration/WebClientConfig.java +++ b/src/main/java/eu/dissco/core/datacitepublisher/configuration/WebClientConfig.java @@ -1,6 +1,7 @@ package eu.dissco.core.datacitepublisher.configuration; import eu.dissco.core.datacitepublisher.properties.DataCiteConnectionProperties; +import eu.dissco.core.datacitepublisher.properties.HandleConnectionProperties; import eu.dissco.core.datacitepublisher.web.WebClientUtils; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; @@ -13,17 +14,29 @@ @RequiredArgsConstructor public class WebClientConfig { - private final DataCiteConnectionProperties properties; - @Bean - public WebClient webClient() { + private final DataCiteConnectionProperties dataciteProperties; + private final HandleConnectionProperties handleProperties; + + @Bean("datacite") + public WebClient dataciteClient() { ExchangeFilterFunction errorResponseFilter = ExchangeFilterFunction .ofResponseProcessor(WebClientUtils::exchangeFilterResponseProcessor); return WebClient.builder() - .baseUrl(properties.getEndpoint()) + .baseUrl(dataciteProperties.getEndpoint()) .filter(errorResponseFilter) .defaultHeader(HttpHeaders.CONTENT_TYPE, "application/vnd.api+json") .defaultHeaders( - header -> header.setBasicAuth(properties.getRepositoryId(), properties.getPassword())) + header -> header.setBasicAuth(dataciteProperties.getRepositoryId(), dataciteProperties.getPassword())) + .build(); + } + + @Bean("handle") + public WebClient handleClient() { + ExchangeFilterFunction errorResponseFilter = ExchangeFilterFunction + .ofResponseProcessor(WebClientUtils::exchangeFilterResponseProcessor); + return WebClient.builder() + .baseUrl(handleProperties.getEndpoint()) + .filter(errorResponseFilter) .build(); } diff --git a/src/main/java/eu/dissco/core/datacitepublisher/controller/RecoveryController.java b/src/main/java/eu/dissco/core/datacitepublisher/controller/RecoveryController.java new file mode 100644 index 0000000..aae72a1 --- /dev/null +++ b/src/main/java/eu/dissco/core/datacitepublisher/controller/RecoveryController.java @@ -0,0 +1,32 @@ +package eu.dissco.core.datacitepublisher.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import eu.dissco.core.datacitepublisher.domain.RecoveryEvent; +import eu.dissco.core.datacitepublisher.exceptions.DataCiteApiException; +import eu.dissco.core.datacitepublisher.exceptions.HandleResolutionException; +import eu.dissco.core.datacitepublisher.service.RecoveryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/datacite-recovery") +@RequiredArgsConstructor +@Slf4j +public class RecoveryController { + + private final RecoveryService recoveryService; + + @PostMapping("") + public ResponseEntity recoverPids(@RequestBody RecoveryEvent event) + throws HandleResolutionException, DataCiteApiException, JsonProcessingException { + recoveryService.recoverDataciteDois(event); + return ResponseEntity.ok(null); + } + + +} diff --git a/src/main/java/eu/dissco/core/datacitepublisher/domain/FdoType.java b/src/main/java/eu/dissco/core/datacitepublisher/domain/FdoType.java new file mode 100644 index 0000000..1dd35de --- /dev/null +++ b/src/main/java/eu/dissco/core/datacitepublisher/domain/FdoType.java @@ -0,0 +1,30 @@ +package eu.dissco.core.datacitepublisher.domain; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public enum FdoType { + @JsonProperty("https://hdl.handle.net/21.T11148/894b1e6cad57e921764e") DIGITAL_SPECIMEN, + @JsonProperty("https://hdl.handle.net/21.T11148/bbad8c4e101e8af01115") MEDIA_OBJECT; + + public static FdoType fromString(String type) { + if ("https://hdl.handle.net/21.T11148/894b1e6cad57e921764e".equals(type)) { + return DIGITAL_SPECIMEN; + } + if ("https://hdl.handle.net/21.T11148/bbad8c4e101e8af01115".equals(type)) { + return MEDIA_OBJECT; + } + log.error("Invalid DOI type: {}", type); + throw new IllegalStateException(); + } + + @Override + public String toString(){ + if (this.equals(DIGITAL_SPECIMEN)) return "https://hdl.handle.net/21.T11148/894b1e6cad57e921764e"; + return "https://hdl.handle.net/21.T11148/bbad8c4e101e8af01115"; + } + + +} diff --git a/src/main/java/eu/dissco/core/datacitepublisher/domain/RecoveryEvent.java b/src/main/java/eu/dissco/core/datacitepublisher/domain/RecoveryEvent.java new file mode 100644 index 0000000..27f46c2 --- /dev/null +++ b/src/main/java/eu/dissco/core/datacitepublisher/domain/RecoveryEvent.java @@ -0,0 +1,10 @@ +package eu.dissco.core.datacitepublisher.domain; + +import java.util.List; + +public record RecoveryEvent( + List handles, + EventType eventType +) { + +} diff --git a/src/main/java/eu/dissco/core/datacitepublisher/exceptions/HandleResolutionException.java b/src/main/java/eu/dissco/core/datacitepublisher/exceptions/HandleResolutionException.java new file mode 100644 index 0000000..034f2bc --- /dev/null +++ b/src/main/java/eu/dissco/core/datacitepublisher/exceptions/HandleResolutionException.java @@ -0,0 +1,9 @@ +package eu.dissco.core.datacitepublisher.exceptions; + +public class HandleResolutionException extends Exception { + + public HandleResolutionException(){ + super(); + } + +} diff --git a/src/main/java/eu/dissco/core/datacitepublisher/properties/HandleConnectionProperties.java b/src/main/java/eu/dissco/core/datacitepublisher/properties/HandleConnectionProperties.java new file mode 100644 index 0000000..cfff136 --- /dev/null +++ b/src/main/java/eu/dissco/core/datacitepublisher/properties/HandleConnectionProperties.java @@ -0,0 +1,20 @@ +package eu.dissco.core.datacitepublisher.properties; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Data +@Validated +@ConfigurationProperties("handle") +public class HandleConnectionProperties { + + @NotBlank + private String endpoint = "https://sandbox.dissco.tech/handle-manager/api/v1/pids/records"; + + @NotNull + private int maxHandles = 100; + +} diff --git a/src/main/java/eu/dissco/core/datacitepublisher/security/JwtAuthConverter.java b/src/main/java/eu/dissco/core/datacitepublisher/security/JwtAuthConverter.java new file mode 100644 index 0000000..376fc34 --- /dev/null +++ b/src/main/java/eu/dissco/core/datacitepublisher/security/JwtAuthConverter.java @@ -0,0 +1,37 @@ +package eu.dissco.core.datacitepublisher.security; + +import jakarta.validation.constraints.NotNull; +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.stereotype.Component; + +@Component +public class JwtAuthConverter implements Converter { + private final JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); + + @Override + public AbstractAuthenticationToken convert(@NotNull Jwt jwt) { + Collection authorities = + converterToStream(jwt).collect(Collectors.toSet()); + return new JwtAuthenticationToken(jwt, authorities, getPrincipalClaimName(jwt)); + } + + private Stream converterToStream(Jwt jwt){ + return Optional.of(jwtGrantedAuthoritiesConverter.convert(jwt)).stream() + .flatMap(Collection::stream); + } + + private String getPrincipalClaimName(Jwt jwt) { + return jwt.getClaim(JwtClaimNames.SUB); + } + +} \ No newline at end of file diff --git a/src/main/java/eu/dissco/core/datacitepublisher/security/MethodSecurityConfig.java b/src/main/java/eu/dissco/core/datacitepublisher/security/MethodSecurityConfig.java new file mode 100644 index 0000000..c8c667c --- /dev/null +++ b/src/main/java/eu/dissco/core/datacitepublisher/security/MethodSecurityConfig.java @@ -0,0 +1,19 @@ +package eu.dissco.core.datacitepublisher.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; +import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; + +@Configuration +@EnableMethodSecurity +public class MethodSecurityConfig { + + @Bean + protected MethodSecurityExpressionHandler createExpressionHandler() { + return new DefaultMethodSecurityExpressionHandler(); + } + +} + diff --git a/src/main/java/eu/dissco/core/datacitepublisher/security/WebSecurityConfig.java b/src/main/java/eu/dissco/core/datacitepublisher/security/WebSecurityConfig.java new file mode 100644 index 0000000..30cab82 --- /dev/null +++ b/src/main/java/eu/dissco/core/datacitepublisher/security/WebSecurityConfig.java @@ -0,0 +1,34 @@ +package eu.dissco.core.datacitepublisher.security; + + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@RequiredArgsConstructor +@Configuration +@EnableWebSecurity +public class WebSecurityConfig { + + private final JwtAuthConverter jwtAuthConverter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests + .anyRequest() + .hasRole("orchestration-admin")); + + http.oauth2ResourceServer(jwtoauth2ResourceServer -> jwtoauth2ResourceServer.jwt(( + jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter) + ))); + + http.sessionManagement(sessionManagement -> sessionManagement + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + return http.build(); + } + +} diff --git a/src/main/java/eu/dissco/core/datacitepublisher/service/RecoveryService.java b/src/main/java/eu/dissco/core/datacitepublisher/service/RecoveryService.java new file mode 100644 index 0000000..7fb23e5 --- /dev/null +++ b/src/main/java/eu/dissco/core/datacitepublisher/service/RecoveryService.java @@ -0,0 +1,84 @@ +package eu.dissco.core.datacitepublisher.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import eu.dissco.core.datacitepublisher.domain.DigitalSpecimenEvent; +import eu.dissco.core.datacitepublisher.domain.EventType; +import eu.dissco.core.datacitepublisher.domain.FdoType; +import eu.dissco.core.datacitepublisher.domain.MediaObjectEvent; +import eu.dissco.core.datacitepublisher.domain.RecoveryEvent; +import eu.dissco.core.datacitepublisher.exceptions.DataCiteApiException; +import eu.dissco.core.datacitepublisher.exceptions.HandleResolutionException; +import eu.dissco.core.datacitepublisher.properties.HandleConnectionProperties; +import eu.dissco.core.datacitepublisher.schemas.DigitalSpecimen; +import eu.dissco.core.datacitepublisher.schemas.MediaObject; +import eu.dissco.core.datacitepublisher.web.HandleClient; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RecoveryService { + + private final HandleClient handleClient; + private final DataCitePublisherService dataCitePublisherService; + private final ObjectMapper mapper; + private final HandleConnectionProperties handleConnectionProperties; + + public void recoverDataciteDois(RecoveryEvent event) + throws HandleResolutionException, JsonProcessingException, DataCiteApiException { + int handlesProcessed = 0; + while (handlesProcessed < event.handles().size()){ + int upperIndex = getUpperIndex(handlesProcessed, event.handles().size()); + var handles = event.handles().subList(handlesProcessed, upperIndex); + processResolvedHandles(handles, event.eventType()); + handlesProcessed = upperIndex; + } + } + + private int getUpperIndex(int handlesProcessed, int totalHandles) { + if (handlesProcessed + handleConnectionProperties.getMaxHandles() > totalHandles) { + return totalHandles; + } + return handlesProcessed + handleConnectionProperties.getMaxHandles(); + } + + private void processResolvedHandles(List handles, EventType eventType) + throws HandleResolutionException, DataCiteApiException, JsonProcessingException { + + var handleResolutionResponse = handleClient.resolveHandles(handles); + if (handleResolutionResponse.get("data") != null && handleResolutionResponse.get("data").isArray()) { + var dataNodes = handleResolutionResponse.get("data"); + for (var pidRecordJson : dataNodes) { + var type = FdoType.fromString(pidRecordJson.get("type").asText()); + if (type.equals(FdoType.DIGITAL_SPECIMEN)) { + recoverDigitalSpecimen(pidRecordJson.get("attributes"), eventType); + } else { + recoverMediaObject(pidRecordJson.get("attributes"), eventType); + } + } + } else { + log.error("Unexpected response from handle api: {}", handleResolutionResponse); + throw new HandleResolutionException(); + } + } + + + + private void recoverDigitalSpecimen(JsonNode pidRecordAttributes, EventType eventType) + throws JsonProcessingException, DataCiteApiException { + var digitalSpecimen = mapper.treeToValue(pidRecordAttributes, DigitalSpecimen.class); + dataCitePublisherService.handleMessages(new DigitalSpecimenEvent(digitalSpecimen, eventType)); + } + + private void recoverMediaObject(JsonNode pidRecordAttributes, EventType eventType) + throws DataCiteApiException, JsonProcessingException { + var mediaObject = mapper.treeToValue(pidRecordAttributes, MediaObject.class); + dataCitePublisherService.handleMessages(new MediaObjectEvent(mediaObject, eventType)); + } + +} diff --git a/src/main/java/eu/dissco/core/datacitepublisher/web/DataCiteClient.java b/src/main/java/eu/dissco/core/datacitepublisher/web/DataCiteClient.java index f155776..c5035a8 100644 --- a/src/main/java/eu/dissco/core/datacitepublisher/web/DataCiteClient.java +++ b/src/main/java/eu/dissco/core/datacitepublisher/web/DataCiteClient.java @@ -6,6 +6,7 @@ import java.util.concurrent.ExecutionException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.BodyInserters; @@ -16,6 +17,7 @@ @Component @Slf4j public class DataCiteClient { + @Qualifier(value = "datacite") private final WebClient webClient; public JsonNode sendDoiRequest(JsonNode requestBody, HttpMethod method, String doi) throws DataCiteApiException { diff --git a/src/main/java/eu/dissco/core/datacitepublisher/web/HandleClient.java b/src/main/java/eu/dissco/core/datacitepublisher/web/HandleClient.java new file mode 100644 index 0000000..0c6c230 --- /dev/null +++ b/src/main/java/eu/dissco/core/datacitepublisher/web/HandleClient.java @@ -0,0 +1,50 @@ +package eu.dissco.core.datacitepublisher.web; + +import com.fasterxml.jackson.databind.JsonNode; +import eu.dissco.core.datacitepublisher.exceptions.DataCiteApiException; +import eu.dissco.core.datacitepublisher.exceptions.HandleResolutionException; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ExecutionException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.util.retry.Retry; + +@RequiredArgsConstructor +@Component +@Slf4j +public class HandleClient { + + @Qualifier(value = "handle") + private final WebClient webClient; + + public JsonNode resolveHandles(List handles) throws HandleResolutionException { + var response = webClient.method(HttpMethod.GET) + .uri(uriBuilder -> uriBuilder + .queryParam("handles", handles) + .build()) + .retrieve() + .bodyToMono(JsonNode.class) + .retryWhen( + Retry.fixedDelay(3, Duration.ofSeconds(2)) + .filter(WebClientUtils::is5xxServerError) + .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> new DataCiteApiException( + "External Service failed to process after max retries"))); + try { + return response.toFuture().get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("An Interrupted Exception has occurred in communicating with the Handle Manager API.", e); + throw new HandleResolutionException(); + } catch (ExecutionException e) { + log.error("An execution Exception with the Handle API has occurred", e); + throw new HandleResolutionException(); + } + + } + +} diff --git a/src/test/java/eu/dissco/core/datacitepublisher/TestUtils.java b/src/test/java/eu/dissco/core/datacitepublisher/TestUtils.java index 59232a3..3ca25c5 100644 --- a/src/test/java/eu/dissco/core/datacitepublisher/TestUtils.java +++ b/src/test/java/eu/dissco/core/datacitepublisher/TestUtils.java @@ -9,6 +9,9 @@ import com.fasterxml.jackson.dataformat.xml.XmlMapper; import eu.dissco.core.datacitepublisher.configuration.InstantDeserializer; import eu.dissco.core.datacitepublisher.configuration.InstantSerializer; +import eu.dissco.core.datacitepublisher.domain.EventType; +import eu.dissco.core.datacitepublisher.domain.FdoType; +import eu.dissco.core.datacitepublisher.domain.RecoveryEvent; import eu.dissco.core.datacitepublisher.domain.datacite.DataCiteConstants; import eu.dissco.core.datacitepublisher.domain.datacite.DcAlternateIdentifier; import eu.dissco.core.datacitepublisher.domain.datacite.DcAttributes; @@ -43,6 +46,8 @@ private TestUtils() { public static final String PREFIX = "10.3535"; public static final String PID = "https://doi.org/" + PREFIX + "/" + SUFFIX; public static final String DOI = PREFIX + "/" + SUFFIX; + public static final String DOI_ALT = PREFIX + "/2RL-RRS-4BX"; + public static final String PID_ALT = "https://doi.org/" + DOI_ALT; public static final String ROR = "https://ror.org/0566bfb96"; public static final String HOST_NAME = "Naturalis Biodiversity Center"; public static final String REFERENT_NAME = "New digital object"; @@ -73,7 +78,11 @@ private TestUtils() { XML_MAPPER = new XmlMapper(); } - public static DcAttributes givenSpecimenAttributes(String doi) { + public static DcAttributes givenSpecimenDataCiteAttributes(){ + return givenSpecimenDataCiteAttributes(DOI); + } + + public static DcAttributes givenSpecimenDataCiteAttributes(String doi) { return DcAttributes.builder() .suffix(SUFFIX) .doi(doi) @@ -107,17 +116,13 @@ public static DcAttributes givenSpecimenAttributes(String doi) { .build(); } - public static DcType givenType(String resourceType){ + public static DcType givenType(String resourceType) { return DcType.builder() .resourceType(resourceType) .build(); } - public static DcAttributes givenSpecimenAttributes() { - return givenSpecimenAttributes(DOI); - } - - public static DcAttributes givenSpecimenAttributesFull() { + public static DcAttributes givenSpecimenDataCiteAttributesFull() { return DcAttributes.builder() .suffix(SUFFIX) .doi(DOI) @@ -166,7 +171,11 @@ public static DcAttributes givenSpecimenAttributesFull() { } public static JsonNode givenSpecimenJson() { - return MAPPER.valueToTree(givenSpecimenAttributes()); + return MAPPER.valueToTree(givenSpecimenDataCiteAttributes()); + } + + public static JsonNode givenSpecimenJson(String doi) { + return MAPPER.valueToTree(givenSpecimenDataCiteAttributes(doi)); } public static DcAttributes givenMediaAttributes() { @@ -282,10 +291,14 @@ private static List givenMediaDescriptionFull() { ); } - public static DigitalSpecimen givenDigitalSpecimen() { + public static DigitalSpecimen givenDigitalSpecimen(){ + return givenDigitalSpecimen(PID); + } + + public static DigitalSpecimen givenDigitalSpecimen(String doi) { return new DigitalSpecimen() .with10320Loc(LOCS) - .withPid(PID) + .withPid(doi) .withIssuedForAgent(ROR) .withIssuedForAgentName(HOST_NAME) .withPidRecordIssueDate(PID_ISSUE_DATE) @@ -304,9 +317,13 @@ public static DigitalSpecimen givenDigitalSpecimenFull() { } public static MediaObject givenMediaObject() { + return givenMediaObject(PID); + } + + public static MediaObject givenMediaObject(String pid) { return new MediaObject() .with10320Loc(LOCS) - .withPid(PID) + .withPid(pid) .withIssuedForAgent(ROR) .withIssuedForAgentName(HOST_NAME) .withPidRecordIssueDate(PID_ISSUE_DATE) @@ -317,11 +334,57 @@ public static MediaObject givenMediaObject() { .withLinkedDigitalObjectType(LinkedDigitalObjectType.DIGITAL_SPECIMEN); } + public static MediaObject givenMediaObjectFull() { return givenMediaObject() .withMediaFormat(MediaFormat.IMAGE); } + public static RecoveryEvent givenRecoveryEvent() { + return new RecoveryEvent(List.of(DOI, DOI_ALT), EventType.CREATE); + } + + public static JsonNode givenDigitalSpecimenPidRecord() { + var specimen1 = MAPPER.createObjectNode() + .put("type", FdoType.DIGITAL_SPECIMEN.toString()) + .set("attributes", MAPPER.valueToTree(givenDigitalSpecimen())); + var specimen2 = MAPPER.createObjectNode() + .put("type", FdoType.DIGITAL_SPECIMEN.toString()) + .set("attributes", MAPPER.valueToTree(givenDigitalSpecimen(PID_ALT))); + + return MAPPER.createObjectNode() + .put("links", "https://dev.dissco.tech/api/v1/pids/records") + .set("data", MAPPER.createArrayNode() + .add(specimen1) + .add(specimen2)); + } + + public static JsonNode givenDigitalSpecimenPidRecordSingle(String pid) { + var specimen1 = MAPPER.createObjectNode() + .put("type", FdoType.DIGITAL_SPECIMEN.toString()) + .set("attributes", MAPPER.valueToTree(givenDigitalSpecimen(pid))); + + return MAPPER.createObjectNode() + .put("links", "https://dev.dissco.tech/api/v1/pids/records") + .set("data", MAPPER.createArrayNode() + .add(specimen1)); + } + + public static JsonNode givenMediaObjectJson() { + var media1 = MAPPER.createObjectNode() + .put("type", FdoType.MEDIA_OBJECT.toString()) + .set("attributes", MAPPER.valueToTree(givenMediaObject())); + var media2 = MAPPER.createObjectNode() + .put("type", FdoType.MEDIA_OBJECT.toString()) + .set("attributes", MAPPER.valueToTree(givenMediaObject(PID_ALT))); + + return MAPPER.createObjectNode() + .put("links", "https://dev.dissco.tech/api/v1/pids/records") + .set("data", MAPPER.createArrayNode() + .add(media1) + .add(media2)); + } + public static DcNameIdentifiers givenIdentifier() { return DcNameIdentifiers diff --git a/src/test/java/eu/dissco/core/datacitepublisher/controller/RecoveryControllerTest.java b/src/test/java/eu/dissco/core/datacitepublisher/controller/RecoveryControllerTest.java new file mode 100644 index 0000000..d746852 --- /dev/null +++ b/src/test/java/eu/dissco/core/datacitepublisher/controller/RecoveryControllerTest.java @@ -0,0 +1,38 @@ +package eu.dissco.core.datacitepublisher.controller; + +import static eu.dissco.core.datacitepublisher.TestUtils.givenRecoveryEvent; +import static org.assertj.core.api.Assertions.assertThat; + +import eu.dissco.core.datacitepublisher.service.RecoveryService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; + +@ExtendWith(MockitoExtension.class) +class RecoveryControllerTest { + + @Mock + private RecoveryService recoveryService; + + private RecoveryController recoveryController; + + @BeforeEach + void init(){ + recoveryController = new RecoveryController(recoveryService); + } + + @Test + void testRecoverPids() throws Exception { + // Given + + // When + var result = recoveryController.recoverPids(givenRecoveryEvent()); + + // Then + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + } + +} diff --git a/src/test/java/eu/dissco/core/datacitepublisher/domain/FdoTypeTest.java b/src/test/java/eu/dissco/core/datacitepublisher/domain/FdoTypeTest.java new file mode 100644 index 0000000..5b93e59 --- /dev/null +++ b/src/test/java/eu/dissco/core/datacitepublisher/domain/FdoTypeTest.java @@ -0,0 +1,34 @@ +package eu.dissco.core.datacitepublisher.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertThrows; + +import org.junit.jupiter.api.Test; + +class FdoTypeTest { + + @Test + void testDsFromString(){ + // When + var result = FdoType.fromString("https://hdl.handle.net/21.T11148/894b1e6cad57e921764e"); + + // Then + assertThat(result).isEqualTo(FdoType.DIGITAL_SPECIMEN); + } + + @Test + void testMoFromString(){ + // When + var result = FdoType.fromString("https://hdl.handle.net/21.T11148/bbad8c4e101e8af01115"); + + // Then + assertThat(result).isEqualTo(FdoType.MEDIA_OBJECT); + } + + @Test + void testBadTypeFromString(){ + // Then + assertThrows(IllegalStateException.class, () -> FdoType.fromString("Bad type")); + } + +} diff --git a/src/test/java/eu/dissco/core/datacitepublisher/service/DataCitePublisherServiceTest.java b/src/test/java/eu/dissco/core/datacitepublisher/service/DataCitePublisherServiceTest.java index 9276565..52cc192 100644 --- a/src/test/java/eu/dissco/core/datacitepublisher/service/DataCitePublisherServiceTest.java +++ b/src/test/java/eu/dissco/core/datacitepublisher/service/DataCitePublisherServiceTest.java @@ -13,14 +13,15 @@ import static eu.dissco.core.datacitepublisher.TestUtils.givenMediaAttributesFull; import static eu.dissco.core.datacitepublisher.TestUtils.givenMediaObject; import static eu.dissco.core.datacitepublisher.TestUtils.givenMediaObjectFull; -import static eu.dissco.core.datacitepublisher.TestUtils.givenSpecimenAttributes; -import static eu.dissco.core.datacitepublisher.TestUtils.givenSpecimenAttributesFull; +import static eu.dissco.core.datacitepublisher.TestUtils.givenSpecimenDataCiteAttributes; +import static eu.dissco.core.datacitepublisher.TestUtils.givenSpecimenDataCiteAttributesFull; import static eu.dissco.core.datacitepublisher.TestUtils.givenType; import static org.junit.Assert.assertThrows; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.lenient; +import eu.dissco.core.datacitepublisher.TestUtils; import eu.dissco.core.datacitepublisher.component.XmlLocReader; import eu.dissco.core.datacitepublisher.domain.DigitalSpecimenEvent; import eu.dissco.core.datacitepublisher.domain.EventType; @@ -69,7 +70,7 @@ void testHandleDigitalSpecimenMessage() throws Exception { var expected = MAPPER.valueToTree( DcRequest.builder() .data(DcData.builder() - .attributes(givenSpecimenAttributes()) + .attributes(givenSpecimenDataCiteAttributes()) .build()) .build()); given(xmlLocReader.getLocationsFromXml(LOCS)).willReturn(LOCS_ARR); @@ -88,7 +89,7 @@ void testHandleDigitalSpecimenMessageUpdate() throws Exception { var expected = MAPPER.valueToTree( DcRequest.builder() .data(DcData.builder() - .attributes(givenSpecimenAttributes()) + .attributes(givenSpecimenDataCiteAttributes()) .build()) .build()); given(xmlLocReader.getLocationsFromXml(LOCS)).willReturn(LOCS_ARR); @@ -107,7 +108,7 @@ void testHandleDigitalSpecimenApiException() throws Exception { var requestBody = MAPPER.valueToTree( DcRequest.builder() .data(DcData.builder() - .attributes(givenSpecimenAttributes()) + .attributes(givenSpecimenDataCiteAttributes()) .build()) .build()); given(xmlLocReader.getLocationsFromXml(LOCS)).willReturn(LOCS_ARR); @@ -125,7 +126,7 @@ void testHandleDigitalSpecimenMessageFull() throws Exception { var expected = MAPPER.valueToTree( DcRequest.builder() .data(DcData.builder() - .attributes(givenSpecimenAttributesFull()) + .attributes(givenSpecimenDataCiteAttributesFull()) .build()) .build()); given(xmlLocReader.getLocationsFromXml(LOCS)).willReturn(LOCS_ARR); @@ -193,7 +194,7 @@ void testHandleDigitalSpecimenMessageTestEnv() throws Exception { var expected = MAPPER.valueToTree( DcRequest.builder() .data(DcData.builder() - .attributes(givenSpecimenAttributes(PREFIX + "/" + SUFFIX)) + .attributes(TestUtils.givenSpecimenDataCiteAttributes(PREFIX + "/" + SUFFIX)) .build()) .build()); given(xmlLocReader.getLocationsFromXml(LOCS)).willReturn(LOCS_ARR); diff --git a/src/test/java/eu/dissco/core/datacitepublisher/service/RecoveryServiceTest.java b/src/test/java/eu/dissco/core/datacitepublisher/service/RecoveryServiceTest.java new file mode 100644 index 0000000..394e0b8 --- /dev/null +++ b/src/test/java/eu/dissco/core/datacitepublisher/service/RecoveryServiceTest.java @@ -0,0 +1,133 @@ +package eu.dissco.core.datacitepublisher.service; + +import static eu.dissco.core.datacitepublisher.TestUtils.DOI; +import static eu.dissco.core.datacitepublisher.TestUtils.DOI_ALT; +import static eu.dissco.core.datacitepublisher.TestUtils.MAPPER; +import static eu.dissco.core.datacitepublisher.TestUtils.PID; +import static eu.dissco.core.datacitepublisher.TestUtils.PID_ALT; +import static eu.dissco.core.datacitepublisher.TestUtils.givenDigitalSpecimen; +import static eu.dissco.core.datacitepublisher.TestUtils.givenDigitalSpecimenPidRecordSingle; +import static eu.dissco.core.datacitepublisher.TestUtils.givenMediaObject; +import static eu.dissco.core.datacitepublisher.TestUtils.givenMediaObjectJson; +import static eu.dissco.core.datacitepublisher.TestUtils.givenRecoveryEvent; +import static eu.dissco.core.datacitepublisher.TestUtils.givenDigitalSpecimenPidRecord; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import eu.dissco.core.datacitepublisher.domain.DigitalSpecimenEvent; +import eu.dissco.core.datacitepublisher.domain.EventType; +import eu.dissco.core.datacitepublisher.domain.MediaObjectEvent; +import eu.dissco.core.datacitepublisher.exceptions.HandleResolutionException; +import eu.dissco.core.datacitepublisher.properties.HandleConnectionProperties; +import eu.dissco.core.datacitepublisher.web.HandleClient; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RecoveryServiceTest { + + @Mock + private HandleClient handleClient; + @Mock + private DataCitePublisherService dataCitePublisherService; + @Mock + private HandleConnectionProperties handleConnectionProperties; + + private RecoveryService recoveryService; + + @BeforeEach + void init() { + recoveryService = new RecoveryService(handleClient, dataCitePublisherService, MAPPER, handleConnectionProperties); + } + + @Test + void testRecoverDoisSpecimen() throws Exception { + // Given + given(handleClient.resolveHandles(List.of(DOI, DOI_ALT))) + .willReturn(givenDigitalSpecimenPidRecord()); + given(handleConnectionProperties.getMaxHandles()).willReturn(10); + + // When + recoveryService.recoverDataciteDois(givenRecoveryEvent()); + + // Then + then(dataCitePublisherService).should() + .handleMessages(new DigitalSpecimenEvent(givenDigitalSpecimen(), EventType.CREATE)); + then(dataCitePublisherService).should() + .handleMessages(new DigitalSpecimenEvent(givenDigitalSpecimen(PID_ALT), EventType.CREATE)); + } + + @Test + void testRecoverDoisSpecimenTwoPages() throws Exception { + // Given + given(handleClient.resolveHandles(List.of(DOI))) + .willReturn(givenDigitalSpecimenPidRecordSingle(PID)); + given(handleClient.resolveHandles(List.of(DOI_ALT))) + .willReturn(givenDigitalSpecimenPidRecordSingle(PID_ALT)); + given(handleConnectionProperties.getMaxHandles()).willReturn(1); + + // When + recoveryService.recoverDataciteDois(givenRecoveryEvent()); + + // Then + then(dataCitePublisherService).should() + .handleMessages(new DigitalSpecimenEvent(givenDigitalSpecimen(), EventType.CREATE)); + then(dataCitePublisherService).should() + .handleMessages(new DigitalSpecimenEvent(givenDigitalSpecimen(PID_ALT), EventType.CREATE)); + } + + @Test + void testRecoverDoisMedia() throws Exception { + // Given + given(handleClient.resolveHandles(List.of(DOI, DOI_ALT))) + .willReturn(givenMediaObjectJson()); + given(handleConnectionProperties.getMaxHandles()).willReturn(10); + + // When + recoveryService.recoverDataciteDois(givenRecoveryEvent()); + + // Then + then(dataCitePublisherService).should() + .handleMessages(new MediaObjectEvent(givenMediaObject(), EventType.CREATE)); + then(dataCitePublisherService).should() + .handleMessages(new MediaObjectEvent(givenMediaObject(PID_ALT), EventType.CREATE)); + } + + @Test + void testRecoverDoisMissingData() throws Exception { + // Given + var handleMessage = MAPPER.readTree(""" + { + "links":"https://dev.dissco.tech/api/v1/pids/records" + } + """); + given(handleClient.resolveHandles(anyList())).willReturn(handleMessage); + given(handleConnectionProperties.getMaxHandles()).willReturn(10); + + // Then + assertThrows(HandleResolutionException.class, () -> recoveryService.recoverDataciteDois(givenRecoveryEvent())); + } + + @Test + void testRecoverDoisDataNotArray() throws Exception { + // Given + var handleMessage = MAPPER.readTree(""" + { + "links":"https://dev.dissco.tech/api/v1/pids/records", + "data": "yep" + } + """); + given(handleClient.resolveHandles(anyList())).willReturn(handleMessage); + given(handleConnectionProperties.getMaxHandles()).willReturn(10); + + // Then + assertThrows(HandleResolutionException.class, () -> recoveryService.recoverDataciteDois(givenRecoveryEvent())); + } + +} diff --git a/src/test/java/eu/dissco/core/datacitepublisher/web/HandleClientTest.java b/src/test/java/eu/dissco/core/datacitepublisher/web/HandleClientTest.java new file mode 100644 index 0000000..6e55749 --- /dev/null +++ b/src/test/java/eu/dissco/core/datacitepublisher/web/HandleClientTest.java @@ -0,0 +1,100 @@ +package eu.dissco.core.datacitepublisher.web; + +import static eu.dissco.core.datacitepublisher.TestUtils.DOI; +import static eu.dissco.core.datacitepublisher.TestUtils.DOI_ALT; +import static eu.dissco.core.datacitepublisher.TestUtils.MAPPER; +import static eu.dissco.core.datacitepublisher.TestUtils.givenDigitalSpecimenPidRecord; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertThrows; + +import eu.dissco.core.datacitepublisher.exceptions.HandleResolutionException; +import java.io.IOException; +import java.util.List; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; + +@ExtendWith(MockitoExtension.class) +class HandleClientTest { + + private static MockWebServer mockHandleServer; + private HandleClient handleClient; + + @BeforeAll + static void init() throws IOException { + mockHandleServer = new MockWebServer(); + mockHandleServer.start(); + } + + + @BeforeEach + void setup() { + ExchangeFilterFunction errorResponseFilter = ExchangeFilterFunction + .ofResponseProcessor(WebClientUtils::exchangeFilterResponseProcessor); + var client = WebClient.builder() + .baseUrl(String.format("http://%s:%s", mockHandleServer.getHostName(), + mockHandleServer.getPort())) + .filter(errorResponseFilter) + .build(); + handleClient = new HandleClient(client); + } + + @AfterAll + static void destroy() throws IOException { + mockHandleServer.shutdown(); + } + + @Test + void testResolveHandle() throws Exception { + // + mockHandleServer.enqueue(new MockResponse().setResponseCode(HttpStatus.OK.value()) + .setBody(MAPPER.writeValueAsString(givenDigitalSpecimenPidRecord())) + .addHeader("Content-Type", "application/json")); + + // When + var response = handleClient.resolveHandles(List.of(DOI, DOI_ALT)); + + // Then + assertThat(response).isEqualTo(givenDigitalSpecimenPidRecord()); + } + + @Test + void testRetries(){ + // Given + int requestCount = mockHandleServer.getRequestCount(); + mockHandleServer.enqueue(new MockResponse().setResponseCode(501)); + mockHandleServer.enqueue(new MockResponse().setResponseCode(501)); + mockHandleServer.enqueue(new MockResponse().setResponseCode(501)); + mockHandleServer.enqueue(new MockResponse().setResponseCode(501)); + + // When + assertThrows(HandleResolutionException.class, + () -> handleClient.resolveHandles(List.of(DOI))); + var newRequestCount = mockHandleServer.getRequestCount(); + + // Then + assertThat(newRequestCount - requestCount).isEqualTo(4); + } + + @Test + void testInterruptedException() { + // Given + mockHandleServer.enqueue(new MockResponse().setResponseCode(HttpStatus.OK.value()) + .addHeader("Content-Type", "application/json")); + + Thread.currentThread().interrupt(); + + // When / Then + assertThrows(HandleResolutionException.class, + () -> handleClient.resolveHandles(List.of(DOI))); + } + +}