From efb4bf5b4ce5f8fc93a9fc06067856b42088f845 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 28 Aug 2024 13:52:05 +0200 Subject: [PATCH 001/108] speedup: use fast, async cache --- build.gradle | 4 +- .../edu/kit/datamanager/pit/Application.java | 41 ++--- .../datamanager/pit/domain/Operations.java | 37 +++-- .../pit/pitservice/ITypingService.java | 3 +- .../impl/EmbeddedStrictValidatorStrategy.java | 68 +++++--- .../pit/pitservice/impl/TypingService.java | 28 ++-- .../pit/typeregistry/impl/TypeRegistry.java | 152 ++++++------------ .../typeregistry/impl/TypeRegistryTest.java | 4 +- 8 files changed, 161 insertions(+), 176 deletions(-) diff --git a/build.gradle b/build.gradle index 5e1a2141..883d56db 100644 --- a/build.gradle +++ b/build.gradle @@ -57,8 +57,8 @@ dependencies { // dependencies. It will automatically choose the fitting ones. implementation("edu.kit.datamanager:service-base:1.2.0") implementation("edu.kit.datamanager:repo-core:1.2.1") - // com.google.common, LoadingCache - implementation("com.google.guava:guava:33.2.1-jre") + // AsyncLoadingCache https://github.com/ben-manes/caffeine + implementation("com.github.ben-manes.caffeine:caffeine:3.1.8") // Required by Spring/Javers at runtime implementation 'com.google.code.gson:gson:2.10.1' diff --git a/src/main/java/edu/kit/datamanager/pit/Application.java b/src/main/java/edu/kit/datamanager/pit/Application.java index 5857e71a..dbd0449f 100644 --- a/src/main/java/edu/kit/datamanager/pit/Application.java +++ b/src/main/java/edu/kit/datamanager/pit/Application.java @@ -19,11 +19,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.CacheLoader; -import com.google.common.cache.LoadingCache; -import com.google.common.cache.RemovalNotification; +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.github.benmanes.caffeine.cache.Caffeine; import edu.kit.datamanager.pit.cli.CliTaskBootstrap; import edu.kit.datamanager.pit.cli.CliTaskWriteFile; import edu.kit.datamanager.pit.cli.ICliTask; @@ -41,8 +39,10 @@ import edu.kit.datamanager.security.filter.KeycloakJwtProperties; import java.io.IOException; -import java.net.URISyntaxException; +import java.time.Duration; import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; @@ -92,6 +92,8 @@ public class Application { protected static final String ERROR_COMMUNICATION = "Communication error: {}"; protected static final String ERROR_CONFIGURATION = "Configuration error: {}"; + protected static final Executor EXECUTOR = Executors.newWorkStealingPool(35); + @Bean @Scope("prototype") @@ -150,21 +152,20 @@ public CacheConfig cacheConfig() { * @return the cache */ @Bean - public LoadingCache typeLoader(ApplicationProperties props) { - int maximumsize = props.getMaximumSize(); - long expireafterwrite = props.getExpireAfterWrite(); - return CacheBuilder.newBuilder() - .maximumSize(maximumsize) - .expireAfterWrite(expireafterwrite, TimeUnit.MINUTES) - .removalListener((RemovalNotification rn) -> LOG.trace( - "Removing type definition located at {} from schema cache. Cause: {}", rn.getKey(), - rn.getCause())) - .build(new CacheLoader() { - @Override - public TypeDefinition load(String typeIdentifier) throws IOException, URISyntaxException { - LOG.trace("Loading type definition for identifier {} to cache.", typeIdentifier); - return typeRegistry().queryTypeDefinition(typeIdentifier); - } + public AsyncLoadingCache typeLoader(ApplicationProperties props) { + int maximumSize = props.getMaximumSize(); + long expireAfterWrite = props.getExpireAfterWrite(); + return Caffeine.newBuilder() + .maximumSize(maximumSize) + .executor(EXECUTOR) + .refreshAfterWrite(Duration.ofMinutes(expireAfterWrite / 2)) + .expireAfterWrite(expireAfterWrite, TimeUnit.MINUTES) + .removalListener((key, value, cause) -> + LOG.trace("Removing type definition located at {} from schema cache. Cause: {}", key, cause) + ) + .buildAsync(pid -> { + LOG.trace("Loading type definition for identifier {} to cache.", pid); + return typeRegistry().queryTypeDefinition(pid); }); } diff --git a/src/main/java/edu/kit/datamanager/pit/domain/Operations.java b/src/main/java/edu/kit/datamanager/pit/domain/Operations.java index dc040ca7..94f5f128 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/Operations.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/Operations.java @@ -8,7 +8,10 @@ import java.util.Date; import java.util.List; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import org.apache.commons.lang3.stream.Streams; import org.joda.time.DateTime; import org.joda.time.format.DateTimeFormatter; import org.joda.time.format.ISODateTimeFormat; @@ -69,14 +72,17 @@ public Optional findDateCreated(PIDRecord pidRecord) throws IOException { /* TODO try to find types extending or relating otherwise to known types * (currently not supported by our TypeDefinition) */ - // we need to resolve types without streams to forward possible exceptions Collection types = new ArrayList<>(); - for (String attributePid : pidRecord.getPropertyIdentifiers()) { - if (this.typingService.isIdentifierRegistered(attributePid)) { - TypeDefinition type = this.typingService.describeType(attributePid); - types.add(type); - } - } + List> futures = Streams + .stream(pidRecord.getPropertyIdentifiers().stream()) + .filter(attributePid -> this.typingService.isIdentifierRegistered(attributePid)) + .map(attributePid -> { + return this.typingService + .describeType(attributePid) + .thenAcceptAsync(types::add); + }) + .collect(Collectors.toList()); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); /* * as a last fallback, try find types with human readable names containing @@ -134,14 +140,17 @@ public Optional findDateModified(PIDRecord pidRecord) throws IOException { /* TODO try to find types extending or relating otherwise to known types * (currently not supported by our TypeDefinition) */ - // we need to resolve types without streams to forward possible exceptions Collection types = new ArrayList<>(); - for (String attributePid : pidRecord.getPropertyIdentifiers()) { - if (this.typingService.isIdentifierRegistered(attributePid)) { - TypeDefinition type = this.typingService.describeType(attributePid); - types.add(type); - } - } + List> futures = Streams + .stream(pidRecord.getPropertyIdentifiers().stream()) + .filter(attributePid -> this.typingService.isIdentifierRegistered(attributePid)) + .map(attributePid -> { + return this.typingService + .describeType(attributePid) + .thenAcceptAsync(types::add); + }) + .collect(Collectors.toList()); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); /* * as a last fallback, try find types with human readable names containing diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/ITypingService.java b/src/main/java/edu/kit/datamanager/pit/pitservice/ITypingService.java index 86fc1923..aafceb7c 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/ITypingService.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/ITypingService.java @@ -6,6 +6,7 @@ import edu.kit.datamanager.pit.domain.PIDRecord; import edu.kit.datamanager.pit.domain.TypeDefinition; import java.io.IOException; +import java.util.concurrent.CompletableFuture; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; @@ -29,7 +30,7 @@ public void validate(PIDRecord pidRecord) * record otherwise. * @throws IOException */ - public TypeDefinition describeType(String typeIdentifier) throws IOException; + public CompletableFuture describeType(String typeIdentifier) throws IOException; /** * Queries a single property from the PID. diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java index 43141fe1..d08224c5 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java @@ -1,5 +1,6 @@ package edu.kit.datamanager.pit.pitservice.impl; +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.configuration.ApplicationProperties; @@ -8,14 +9,16 @@ import edu.kit.datamanager.pit.pitservice.IValidationStrategy; import edu.kit.datamanager.pit.util.TypeValidationUtils; -import java.util.concurrent.ExecutionException; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.*; +import java.util.stream.Collectors; +import org.apache.commons.lang3.stream.Streams; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import com.google.common.cache.LoadingCache; - /** * Validates a PID record using embedded profile(s). * @@ -28,7 +31,7 @@ public class EmbeddedStrictValidatorStrategy implements IValidationStrategy { private static final Logger LOG = LoggerFactory.getLogger(EmbeddedStrictValidatorStrategy.class); @Autowired - public LoadingCache typeLoader; + public AsyncLoadingCache typeLoader; @Autowired ApplicationProperties applicationProps; @@ -50,25 +53,43 @@ public void validate(PIDRecord pidRecord) throws RecordValidationException, Exte "Profile attribute " + profileKey + " has no values."); } - for (String profilePID : profilePIDs) { - TypeDefinition profileDefinition; - try { - profileDefinition = this.typeLoader.get(profilePID); - } catch (ExecutionException e) { - LOG.error("Could not resolve identifier {}.", profilePID); - throw new ExternalServiceException( - applicationProps.getTypeRegistryUri().toString()); - } - if (profileDefinition == null) { - LOG.error("No type definition found for identifier {}.", profilePID); - throw new RecordValidationException( - pidRecord, - String.format("No type found for identifier %s.", profilePID)); - } + List> futures = Streams.stream(Arrays.stream(profilePIDs)) + .map(profilePID -> { + try { + return this.typeLoader.get(profilePID) + .thenAcceptAsync(profileDefinition -> { + if (profileDefinition == null) { + LOG.error("No type definition found for identifier {}.", profilePID); + throw new RecordValidationException( + pidRecord, + String.format("No type found for identifier %s.", profilePID)); + } + this.strictProfileValidation(pidRecord, profileDefinition); + }); + } catch (RuntimeException e) { + LOG.error("Could not resolve identifier {}.", profilePID); + throw new ExternalServiceException( + applicationProps.getTypeRegistryUri().toString()); + } + }) + .collect(Collectors.toList()); + try { + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + } catch (CompletionException e) { + throwRecordValidationExceptionCause(e); + throw new ExternalServiceException( + applicationProps.getTypeRegistryUri().toString()); + } catch (CancellationException e) { + throwRecordValidationExceptionCause(e); + throw new RecordValidationException( + pidRecord, + String.format("Validation task was cancelled for %s. Please report.", pidRecord.getPid())); + } + } - LOG.debug("validating profile {}", profilePID); - this.strictProfileValidation(pidRecord, profileDefinition); - LOG.debug("successfully validated {}", profilePID); + private static void throwRecordValidationExceptionCause(Throwable e) { + if (e.getCause() instanceof RecordValidationException rve) { + throw rve; } } @@ -87,7 +108,7 @@ private void strictProfileValidation(PIDRecord pidRecord, TypeDefinition profile // return profile.validate(jsonRecord); // } - LOG.trace("Validating PID record against type definition."); + LOG.trace("Validating PID record against profile {}.", profile.getIdentifier()); TypeValidationUtils.checkMandatoryAttributes(pidRecord, profile); @@ -109,6 +130,7 @@ private void strictProfileValidation(PIDRecord pidRecord, TypeDefinition profile validateValuesForKey(pidRecord, attributeKey, type); } + LOG.debug("successfully validated {}", profile.getIdentifier()); } /** diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/TypingService.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/TypingService.java index 0e556933..4a77b054 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/TypingService.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/TypingService.java @@ -1,16 +1,13 @@ package edu.kit.datamanager.pit.pitservice.impl; -import com.google.common.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; import edu.kit.datamanager.pit.common.InvalidConfigException; import edu.kit.datamanager.pit.common.PidAlreadyExistsException; import edu.kit.datamanager.pit.common.PidNotFoundException; import edu.kit.datamanager.pit.common.RecordValidationException; -import edu.kit.datamanager.pit.common.TypeNotFoundException; import java.io.IOException; import java.util.Collection; -import java.util.HashSet; -import java.util.List; import java.util.Optional; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; @@ -21,6 +18,8 @@ import edu.kit.datamanager.pit.domain.Operations; import edu.kit.datamanager.pit.domain.PIDRecord; import edu.kit.datamanager.pit.domain.TypeDefinition; + +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import org.slf4j.Logger; @@ -39,7 +38,7 @@ public class TypingService implements ITypingService { private static final String LOG_MSG_QUERY_TYPE = "Querying for type with identifier {}."; - protected final LoadingCache typeCache; + protected final AsyncLoadingCache typeCache; protected final IIdentifierSystem identifierSystem; protected final ITypeRegistry typeRegistry; @@ -54,7 +53,7 @@ public class TypingService implements ITypingService { protected IValidationStrategy defaultStrategy = null; public TypingService(IIdentifierSystem identifierSystem, ITypeRegistry typeRegistry, - LoadingCache typeCache) { + AsyncLoadingCache typeCache) { super(); this.identifierSystem = identifierSystem; this.typeRegistry = typeRegistry; @@ -108,12 +107,12 @@ public boolean deletePID(String pid) throws ExternalServiceException { } @Override - public TypeDefinition describeType(String typeIdentifier) throws IOException { + public CompletableFuture describeType(String typeIdentifier) throws IOException { LOG.trace("Performing describeType({}).", typeIdentifier); try { LOG.trace(LOG_MSG_QUERY_TYPE, typeIdentifier); return typeCache.get(typeIdentifier); - } catch (ExecutionException ex) { + } catch (RuntimeException ex) { LOG.error("Failed to query for type with identifier " + typeIdentifier + ".", ex); throw new InvalidConfigException(LOG_MSG_TYPING_SERVICE_MISCONFIGURED); } @@ -152,10 +151,9 @@ public PIDRecord queryProperty(String pid, String propertyIdentifier) throws IOE TypeDefinition typeDef; try { LOG.trace(LOG_MSG_QUERY_TYPE, propertyIdentifier); - typeDef = typeCache.get(propertyIdentifier); - } catch (ExecutionException ex) { + typeDef = typeCache.get(propertyIdentifier).get(); + } catch (ExecutionException | InterruptedException ex) { LOG.error(LOG_MSG_QUERY_TYPE, propertyIdentifier); - throw new InvalidConfigException(LOG_MSG_TYPING_SERVICE_MISCONFIGURED); } @@ -172,8 +170,8 @@ private void enrichPIDInformationRecord(PIDRecord pidInfo) { for (String typeIdentifier : pidInfo.getPropertyIdentifiers()) { TypeDefinition typeDef; try { - typeDef = typeCache.get(typeIdentifier); - } catch (ExecutionException ex) { + typeDef = typeCache.get(typeIdentifier).get(); + } catch (ExecutionException | InterruptedException ex) { throw new InvalidConfigException(LOG_MSG_TYPING_SERVICE_MISCONFIGURED); } @@ -190,8 +188,8 @@ public PIDRecord queryByType(String pid, String typeIdentifier, boolean includeP throws IOException { TypeDefinition typeDef; try { - typeDef = typeCache.get(typeIdentifier); - } catch (ExecutionException ex) { + typeDef = typeCache.get(typeIdentifier).get(); + } catch (ExecutionException | InterruptedException ex) { throw new InvalidConfigException(LOG_MSG_TYPING_SERVICE_MISCONFIGURED); } diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistry.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistry.java index 0d016d27..f43530da 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistry.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistry.java @@ -1,13 +1,14 @@ package edu.kit.datamanager.pit.typeregistry.impl; import java.io.IOException; -import java.util.HashMap; +import java.util.Date; +import java.util.List; import java.util.Map; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.cache.LoadingCache; +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.domain.ProvenanceInformation; import edu.kit.datamanager.pit.domain.TypeDefinition; @@ -15,8 +16,11 @@ import java.net.URISyntaxException; import java.time.Instant; import java.time.format.DateTimeParseException; -import java.util.Date; -import java.util.concurrent.ExecutionException; +import java.util.concurrent.*; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +import org.apache.commons.lang3.stream.Streams; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -36,8 +40,9 @@ public class TypeRegistry implements ITypeRegistry { private static final Logger LOG = LoggerFactory.getLogger(TypeRegistry.class); + @Autowired - public LoadingCache typeCache; + public AsyncLoadingCache typeCache; @Autowired private ApplicationProperties applicationProperties; @@ -66,111 +71,59 @@ public TypeDefinition queryTypeDefinition(String typeIdentifier) throws IOExcept * Helper method to construct a type definition from a JSON response * received from the TypeRegistry. * - * @param rootNode The type definition. - * + * @param registryRepresentation The type definition. * @return The TypeDefinition as object. */ - private TypeDefinition constructTypeDefinition(JsonNode rootNode) + private TypeDefinition constructTypeDefinition(JsonNode registryRepresentation) throws JsonProcessingException, IOException, URISyntaxException { // TODO We are doing things too complicated here. Deserialization should be // easy. // But before we change the domain model to do so, we need a lot of tests to // make sure things work as before after the changes. LOG.trace("Performing constructTypeDefinition()."); - JsonNode entry = rootNode; - Map properties = new HashMap<>(); - LOG.trace("Checking for 'properties' attribute."); - if (entry.has("properties")) { - LOG.trace("'properties' attribute found. Transferring properties to type definition."); - for (JsonNode entryKV : entry.get("properties")) { - LOG.trace("Checking for 'name' property."); - if (!entryKV.has("name")) { - LOG.trace("No 'name' property found. Skipping property {}.", entryKV); - continue; - } - - String key = entryKV.get("name").asText(); - - if (!entryKV.has("identifier")) { - LOG.trace("No 'identifier' property found. Skipping property {}.", entryKV); - continue; - } - - String value = entryKV.get("identifier").asText(); - LOG.trace("Creating type definition instance for identifier {}.", value); - TypeDefinition type_def; - - try { - type_def = typeCache.get(value); - } catch (ExecutionException ex) { - throw new IOException("Failed to obtain type definition via cache.", ex); - } - - LOG.trace("Checking for sub-types in 'representationsAndSemantics' property."); - if (entryKV.has("representationsAndSemantics")) { - LOG.trace( - "'representationsAndSemantics' attribute found. Transferring properties to type definition."); - JsonNode semNode = entryKV.get("representationsAndSemantics"); - semNode = semNode.get(0); - LOG.trace("Checking for 'expression' property."); - if (semNode.has("expression")) { - LOG.trace("Setting 'expression' value {}.", semNode.get("expression").asText()); - type_def.setExpression(semNode.get("expression").asText()); - } - - LOG.trace("Checking for 'value' property."); - if (semNode.has("value")) { - LOG.trace("Setting 'value' value {}.", semNode.get("value").asText()); - type_def.setValue(semNode.get("value").asText()); - } - - LOG.trace("Checking for 'obligation' property."); - if (semNode.has("obligation")) { - LOG.trace("Setting 'obligation' value {}.", semNode.get("obligation").asText()); - String obligation = semNode.get("obligation").asText(); - type_def.setOptional("Optional".equalsIgnoreCase(obligation)); - } - - LOG.trace("Checking for 'repeatable' property."); - if (semNode.has("repeatable")) { - LOG.trace("Setting 'repeatable' value {}.", semNode.get("repeatable").asText()); - String repeatable = semNode.get("repeatable").asText(); - type_def.setRepeatable(!"No".equalsIgnoreCase(repeatable)); - } - } - LOG.trace("Adding new sub-type with key {}.", key); - properties.put(key, type_def); - } - } - String typeUseExpl = null; - if (entry.has("description")) { - typeUseExpl = entry.get("description").asText(); - } - String name = null; - if (entry.has("name")) { - name = entry.get("name").asText(); - } - - if (!entry.has("identifier")) { - LOG.error("No 'identifier' property found in entry: {}", entry); + final String identifier = registryRepresentation.path("identifier").asText(null); + if (identifier == null) { + LOG.error("No 'identifier' property found in entry: {}", registryRepresentation); throw new IOException("No 'identifier' attribute found in type definition."); } - String identifier = entry.get("identifier").asText(); + + LOG.trace("Checking for 'properties' attribute."); + Map properties = new ConcurrentHashMap<>(); + List> propertiesHandling = Streams.stream(StreamSupport.stream( + registryRepresentation.path("properties").spliterator(), false)) + .filter(property -> property.hasNonNull("name")) + .filter(property -> property.hasNonNull("identifier")) + .map(property -> { + final String name = property.path("name").asText(); + final String pid = property.path("identifier").asText(); + return typeCache.get(pid).thenAcceptAsync( + typeDefinition -> { + final JsonNode semantics = property.path("representationsAndSemantics").path(0); + final String expression = semantics.path("expression").asText(null); + typeDefinition.setExpression(expression); + final String value = semantics.path("value").asText(null); + typeDefinition.setValue(value); + final String obligation = semantics.path("obligation").asText("Mandatory"); + typeDefinition.setOptional("Optional".equalsIgnoreCase(obligation)); + final String repeatable = semantics.path("repeatable").asText("No"); + typeDefinition.setRepeatable(!"No".equalsIgnoreCase(repeatable)); + properties.put(name, typeDefinition); + }); + }) + .collect(Collectors.toList()); TypeDefinition result = new TypeDefinition(); - result.setName(name); - result.setDescription(typeUseExpl); result.setIdentifier(identifier); - LOG.trace("Checking for 'validationSchema' property."); - if (entry.has("validationSchema")) { - String validationSchema = entry.get("validationSchema").asText(); - result.setSchema(validationSchema); - } + final String description = registryRepresentation.path("description").asText(null); + result.setDescription(description); + final String name = registryRepresentation.path("name").asText(null); + result.setName(name); + final String validationSchema = registryRepresentation.path("validationSchema").asText(null); + result.setSchema(validationSchema); - LOG.trace("Checking for 'provenance' property."); - if (entry.has("provenance")) { + if (registryRepresentation.has("provenance")) { ProvenanceInformation prov = new ProvenanceInformation(); - JsonNode provNode = entry.get("provenance"); + JsonNode provNode = registryRepresentation.get("provenance"); if (provNode.has("creationDate")) { String creationDate = provNode.get("creationDate").asText(); try { @@ -192,13 +145,13 @@ private TypeDefinition constructTypeDefinition(JsonNode rootNode) String contributorName = null; String details = null; - if (entry.has("identifiedBy")) { + if (registryRepresentation.has("identifiedBy")) { identified = entryKV.get("identifiedBy").asText(); } - if (entry.has("name")) { + if (registryRepresentation.has("name")) { contributorName = entryKV.get("name").asText(); } - if (entry.has("details")) { + if (registryRepresentation.has("details")) { details = entryKV.get("details").asText(); } prov.addContributor(identified, contributorName, details); @@ -207,8 +160,9 @@ private TypeDefinition constructTypeDefinition(JsonNode rootNode) } LOG.trace("Finalizing and returning type definition."); + CompletableFuture.allOf(propertiesHandling.toArray(new CompletableFuture[0])).join(); properties.keySet().forEach(pd -> result.addSubType(properties.get(pd))); - this.typeCache.put(identifier, result); + this.typeCache.put(identifier, CompletableFuture.completedFuture(result)); return result; } } diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistryTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistryTest.java index cc056d0e..2bb7db38 100644 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistryTest.java +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistryTest.java @@ -36,7 +36,7 @@ void isCachingProfiles() throws IOException, URISyntaxException { assertEquals( null, typeRegistry.typeCache.getIfPresent(profileIdentifier)); - assertEquals(0, typeRegistry.typeCache.size()); + assertEquals(0, typeRegistry.typeCache.synchronous().estimatedSize()); typeRegistry.queryTypeDefinition(profileIdentifier); assertNotEquals( @@ -44,6 +44,6 @@ void isCachingProfiles() throws IOException, URISyntaxException { typeRegistry.typeCache.getIfPresent(profileIdentifier)); // A profile definition contains type definitions. // The cache therefore should have more than one identifiers in cache. - assertTrue(typeRegistry.typeCache.size() > 1); + assertTrue(typeRegistry.typeCache.synchronous().estimatedSize() > 1); } } From 160bfe0717140b0c473224e424aaeaaa84aa9e92 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Thu, 29 Aug 2024 15:36:55 +0200 Subject: [PATCH 002/108] speedup: use default work stealing executor for "async" cache --- src/main/java/edu/kit/datamanager/pit/Application.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/edu/kit/datamanager/pit/Application.java b/src/main/java/edu/kit/datamanager/pit/Application.java index dbd0449f..0de3b205 100644 --- a/src/main/java/edu/kit/datamanager/pit/Application.java +++ b/src/main/java/edu/kit/datamanager/pit/Application.java @@ -92,7 +92,7 @@ public class Application { protected static final String ERROR_COMMUNICATION = "Communication error: {}"; protected static final String ERROR_CONFIGURATION = "Configuration error: {}"; - protected static final Executor EXECUTOR = Executors.newWorkStealingPool(35); + protected static final Executor EXECUTOR = Executors.newWorkStealingPool(); @Bean From f976bddfa3e0c3b2ef2a59d40da71c71b8211222 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Thu, 29 Aug 2024 15:48:43 +0200 Subject: [PATCH 003/108] speedup: use extra executors for validation and deserialization --- .../pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java | 3 ++- .../kit/datamanager/pit/typeregistry/impl/TypeRegistry.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java index d08224c5..afc65d97 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java @@ -29,6 +29,7 @@ public class EmbeddedStrictValidatorStrategy implements IValidationStrategy { private static final Logger LOG = LoggerFactory.getLogger(EmbeddedStrictValidatorStrategy.class); + protected static final Executor EXECUTOR = Executors.newWorkStealingPool(); @Autowired public AsyncLoadingCache typeLoader; @@ -65,7 +66,7 @@ public void validate(PIDRecord pidRecord) throws RecordValidationException, Exte String.format("No type found for identifier %s.", profilePID)); } this.strictProfileValidation(pidRecord, profileDefinition); - }); + }, EXECUTOR); } catch (RuntimeException e) { LOG.error("Could not resolve identifier {}.", profilePID); throw new ExternalServiceException( diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistry.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistry.java index f43530da..8e552930 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistry.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistry.java @@ -40,6 +40,7 @@ public class TypeRegistry implements ITypeRegistry { private static final Logger LOG = LoggerFactory.getLogger(TypeRegistry.class); + protected static final Executor EXECUTOR = Executors.newWorkStealingPool(20); @Autowired public AsyncLoadingCache typeCache; @@ -108,7 +109,7 @@ private TypeDefinition constructTypeDefinition(JsonNode registryRepresentation) final String repeatable = semantics.path("repeatable").asText("No"); typeDefinition.setRepeatable(!"No".equalsIgnoreCase(repeatable)); properties.put(name, typeDefinition); - }); + }, EXECUTOR); }) .collect(Collectors.toList()); From c7e6cbbdcf492dba6ca107ec2bbb2da56c1a6cf7 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Sat, 16 Nov 2024 01:25:59 +0100 Subject: [PATCH 004/108] chore: rename TypeRegistry to DtrTest, as it depends on dtr-test schemas and structure --- .../edu/kit/datamanager/pit/Application.java | 530 +++++++++--------- .../impl/{TypeRegistry.java => DtrTest.java} | 336 ++++++----- ...TypeRegistryTest.java => DtrTestTest.java} | 4 +- 3 files changed, 434 insertions(+), 436 deletions(-) rename src/main/java/edu/kit/datamanager/pit/typeregistry/impl/{TypeRegistry.java => DtrTest.java} (95%) rename src/test/java/edu/kit/datamanager/pit/typeregistry/impl/{TypeRegistryTest.java => DtrTestTest.java} (96%) diff --git a/src/main/java/edu/kit/datamanager/pit/Application.java b/src/main/java/edu/kit/datamanager/pit/Application.java index 0de3b205..219118ac 100644 --- a/src/main/java/edu/kit/datamanager/pit/Application.java +++ b/src/main/java/edu/kit/datamanager/pit/Application.java @@ -1,265 +1,265 @@ -/* - * Copyright 2018 Karlsruhe Institute of Technology. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package edu.kit.datamanager.pit; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.github.benmanes.caffeine.cache.AsyncLoadingCache; - -import com.github.benmanes.caffeine.cache.Caffeine; -import edu.kit.datamanager.pit.cli.CliTaskBootstrap; -import edu.kit.datamanager.pit.cli.CliTaskWriteFile; -import edu.kit.datamanager.pit.cli.ICliTask; -import edu.kit.datamanager.pit.cli.PidSource; -import edu.kit.datamanager.pit.common.InvalidConfigException; -import edu.kit.datamanager.pit.configuration.ApplicationProperties; -import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; -import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; -import edu.kit.datamanager.pit.pitservice.ITypingService; -import edu.kit.datamanager.pit.pitservice.impl.TypingService; -import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; -import edu.kit.datamanager.pit.typeregistry.impl.TypeRegistry; -import edu.kit.datamanager.pit.web.converter.SimplePidRecordConverter; -import edu.kit.datamanager.security.filter.KeycloakJwtProperties; - -import java.io.IOException; -import java.time.Duration; -import java.util.Objects; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.stream.Stream; - -import org.apache.http.client.HttpClient; -import org.apache.http.impl.client.cache.CacheConfig; -import org.apache.http.impl.client.cache.CachingHttpClientBuilder; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.springframework.beans.factory.InjectionPoint; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Scope; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; -import org.springframework.http.converter.HttpMessageConverter; -import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; -import org.springframework.scheduling.annotation.EnableScheduling; - -/** - * - * @author jejkal - */ -@SpringBootApplication -@EnableScheduling -@EntityScan({ "edu.kit.datamanager" }) -// Required for "DAO" objects to work, needed for messaging service and database -// mappings -@EnableJpaRepositories("edu.kit.datamanager") -// Detects services and components in datamanager dependencies (service-base and -// repo-core) -@ComponentScan({ "edu.kit.datamanager" }) -public class Application { - - private static final Logger LOG = LoggerFactory.getLogger(Application.class); - - protected static final String CMD_BOOTSTRAP = "bootstrap"; - protected static final String CMD_WRITE_FILE = "write-file"; - - protected static final String SOURCE_FROM_PREFIX = "all-pids-from-prefix"; - protected static final String SOURCE_KNOWN_PIDS = "known-pids"; - - protected static final String ERROR_COMMUNICATION = "Communication error: {}"; - protected static final String ERROR_CONFIGURATION = "Configuration error: {}"; - - protected static final Executor EXECUTOR = Executors.newWorkStealingPool(); - - - @Bean - @Scope("prototype") - public Logger logger(InjectionPoint injectionPoint) { - Class targetClass = injectionPoint.getMember().getDeclaringClass(); - return LoggerFactory.getLogger(targetClass.getCanonicalName()); - } - - @Bean - public ITypeRegistry typeRegistry() { - return new TypeRegistry(); - } - - @Bean - public ITypingService typingService(IIdentifierSystem identifierSystem, ApplicationProperties props) { - return new TypingService(identifierSystem, typeRegistry(), typeLoader(props)); - } - - @Bean(name = "OBJECT_MAPPER_BEAN") - public static ObjectMapper jsonObjectMapper() { - return Jackson2ObjectMapperBuilder.json() - .serializationInclusion(JsonInclude.Include.NON_EMPTY) // Don’t include null values - .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // ISODate - .modules(new JavaTimeModule()) - .build(); - } - - @Bean - public HttpClient httpClient() { - return CachingHttpClientBuilder - .create() - .setCacheConfig(cacheConfig()) - .build(); - } - - @Bean - public CacheConfig cacheConfig() { - return CacheConfig - .custom() - .setMaxObjectSize(500000) // 500KB - .setMaxCacheEntries(2000) - // Set this to false and a response with queryString - // will be cached when it is explicitly cacheable - // .setNeverCacheHTTP10ResponsesWithQueryString(false) - .build(); - } - - /** - * This loader is a cache, which will retrieve `TypeDefinition`s, if required. - * - * Therefore, it can be used instead of the ITypeRegistry implementations. - * Retrieve it using Autowire or from the application context. - * - * @param props the applications properties set by the administration at the - * start of this application. - * @return the cache - */ - @Bean - public AsyncLoadingCache typeLoader(ApplicationProperties props) { - int maximumSize = props.getMaximumSize(); - long expireAfterWrite = props.getExpireAfterWrite(); - return Caffeine.newBuilder() - .maximumSize(maximumSize) - .executor(EXECUTOR) - .refreshAfterWrite(Duration.ofMinutes(expireAfterWrite / 2)) - .expireAfterWrite(expireAfterWrite, TimeUnit.MINUTES) - .removalListener((key, value, cause) -> - LOG.trace("Removing type definition located at {} from schema cache. Cause: {}", key, cause) - ) - .buildAsync(pid -> { - LOG.trace("Loading type definition for identifier {} to cache.", pid); - return typeRegistry().queryTypeDefinition(pid); - }); - } - - @ConfigurationProperties("pit") - public ApplicationProperties applicationProperties() { - return new ApplicationProperties(); - } - - @Bean - // Reads keycloak related settings from properties.application. - public KeycloakJwtProperties properties() { - return new KeycloakJwtProperties(); - } - - @Bean - public HttpMessageConverter simplePidRecordConverter() { - return new SimplePidRecordConverter(); - } - - public static void main(String[] args) { - ConfigurableApplicationContext context = SpringApplication.run(Application.class, args); - System.out.println("Spring is running!"); - - final boolean cliArgsAmountValid = args != null && args.length != 0 && args.length >= 2; - - if (cliArgsAmountValid) { - ICliTask task = null; - Stream pidSource = null; - - if (Objects.equals(args[1], SOURCE_FROM_PREFIX)) { - try { - pidSource = PidSource.fromPrefix(context); - } catch (IOException e) { - e.printStackTrace(); - LOG.error(ERROR_COMMUNICATION, e.getMessage()); - exitApp(context, 1); - } - } else if (Objects.equals(args[1], SOURCE_KNOWN_PIDS)) { - pidSource = PidSource.fromKnown(context); - } - - if (Objects.equals(args[0], CMD_BOOTSTRAP)) { - task = new CliTaskBootstrap(context, pidSource); - } else if (Objects.equals(args[0], CMD_WRITE_FILE)) { - task = new CliTaskWriteFile(pidSource); - } - - try { - if (task != null && pidSource != null) { - // ---process task--- - if (task.process()) { - exitApp(context, 0); - } - } else { - printUsage(args); - exitApp(context, 1); - } - } catch (InvalidConfigException e) { - e.printStackTrace(); - LOG.error(ERROR_CONFIGURATION, e.getMessage()); - exitApp(context, 1); - } catch (IOException e) { - e.printStackTrace(); - LOG.error(ERROR_COMMUNICATION, e.getMessage()); - exitApp(context, 1); - } - } - } - - private static void printUsage(String[] args) { - String firstArg = args[0].replaceAll("[\r\n]",""); - String secondArg = args[1].replaceAll("[\r\n]",""); - LOG.error("Got commands: {} and {}", firstArg, secondArg); - LOG.error("CLI usage incorrect. Usage:"); - LOG.error("java -jar TypedPIDMaker.jar [ACTION] [SOURCE]"); - LOG.error("java -jar TypedPIDMaker.jar bootstrap all-pids-from-prefix"); - LOG.error("java -jar TypedPIDMaker.jar bootstrap known-pids"); - LOG.error("java -jar TypedPIDMaker.jar write-file all-pids-from-prefix"); - LOG.error("java -jar TypedPIDMaker.jar write-file known-pids"); - } - - private static void exitApp(ConfigurableApplicationContext context, int errCode) { - context.close(); - try { - Thread.sleep(2 * 1000L); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - if (errCode != 0) { - LOG.error("Exited with error."); - } else { - LOG.info("Success"); - } - System.exit(errCode); - } - -} +/* + * Copyright 2018 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package edu.kit.datamanager.pit; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; + +import com.github.benmanes.caffeine.cache.Caffeine; +import edu.kit.datamanager.pit.cli.CliTaskBootstrap; +import edu.kit.datamanager.pit.cli.CliTaskWriteFile; +import edu.kit.datamanager.pit.cli.ICliTask; +import edu.kit.datamanager.pit.cli.PidSource; +import edu.kit.datamanager.pit.common.InvalidConfigException; +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import edu.kit.datamanager.pit.domain.PIDRecord; +import edu.kit.datamanager.pit.domain.TypeDefinition; +import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; +import edu.kit.datamanager.pit.pitservice.ITypingService; +import edu.kit.datamanager.pit.pitservice.impl.TypingService; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; +import edu.kit.datamanager.pit.typeregistry.impl.DtrTest; +import edu.kit.datamanager.pit.web.converter.SimplePidRecordConverter; +import edu.kit.datamanager.security.filter.KeycloakJwtProperties; + +import java.io.IOException; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import org.apache.http.client.HttpClient; +import org.apache.http.impl.client.cache.CacheConfig; +import org.apache.http.impl.client.cache.CachingHttpClientBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.beans.factory.InjectionPoint; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Scope; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * + * @author jejkal + */ +@SpringBootApplication +@EnableScheduling +@EntityScan({ "edu.kit.datamanager" }) +// Required for "DAO" objects to work, needed for messaging service and database +// mappings +@EnableJpaRepositories("edu.kit.datamanager") +// Detects services and components in datamanager dependencies (service-base and +// repo-core) +@ComponentScan({ "edu.kit.datamanager" }) +public class Application { + + private static final Logger LOG = LoggerFactory.getLogger(Application.class); + + protected static final String CMD_BOOTSTRAP = "bootstrap"; + protected static final String CMD_WRITE_FILE = "write-file"; + + protected static final String SOURCE_FROM_PREFIX = "all-pids-from-prefix"; + protected static final String SOURCE_KNOWN_PIDS = "known-pids"; + + protected static final String ERROR_COMMUNICATION = "Communication error: {}"; + protected static final String ERROR_CONFIGURATION = "Configuration error: {}"; + + protected static final Executor EXECUTOR = Executors.newWorkStealingPool(); + + + @Bean + @Scope("prototype") + public Logger logger(InjectionPoint injectionPoint) { + Class targetClass = injectionPoint.getMember().getDeclaringClass(); + return LoggerFactory.getLogger(targetClass.getCanonicalName()); + } + + @Bean + public ITypeRegistry typeRegistry() { + return new DtrTest(); + } + + @Bean + public ITypingService typingService(IIdentifierSystem identifierSystem, ApplicationProperties props) { + return new TypingService(identifierSystem, typeRegistry(), typeLoader(props)); + } + + @Bean(name = "OBJECT_MAPPER_BEAN") + public static ObjectMapper jsonObjectMapper() { + return Jackson2ObjectMapperBuilder.json() + .serializationInclusion(JsonInclude.Include.NON_EMPTY) // Don’t include null values + .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // ISODate + .modules(new JavaTimeModule()) + .build(); + } + + @Bean + public HttpClient httpClient() { + return CachingHttpClientBuilder + .create() + .setCacheConfig(cacheConfig()) + .build(); + } + + @Bean + public CacheConfig cacheConfig() { + return CacheConfig + .custom() + .setMaxObjectSize(500000) // 500KB + .setMaxCacheEntries(2000) + // Set this to false and a response with queryString + // will be cached when it is explicitly cacheable + // .setNeverCacheHTTP10ResponsesWithQueryString(false) + .build(); + } + + /** + * This loader is a cache, which will retrieve `TypeDefinition`s, if required. + * + * Therefore, it can be used instead of the ITypeRegistry implementations. + * Retrieve it using Autowire or from the application context. + * + * @param props the applications properties set by the administration at the + * start of this application. + * @return the cache + */ + @Bean + public AsyncLoadingCache typeLoader(ApplicationProperties props) { + int maximumSize = props.getMaximumSize(); + long expireAfterWrite = props.getExpireAfterWrite(); + return Caffeine.newBuilder() + .maximumSize(maximumSize) + .executor(EXECUTOR) + .refreshAfterWrite(Duration.ofMinutes(expireAfterWrite / 2)) + .expireAfterWrite(expireAfterWrite, TimeUnit.MINUTES) + .removalListener((key, value, cause) -> + LOG.trace("Removing type definition located at {} from schema cache. Cause: {}", key, cause) + ) + .buildAsync(pid -> { + LOG.trace("Loading type definition for identifier {} to cache.", pid); + return typeRegistry().queryTypeDefinition(pid); + }); + } + + @ConfigurationProperties("pit") + public ApplicationProperties applicationProperties() { + return new ApplicationProperties(); + } + + @Bean + // Reads keycloak related settings from properties.application. + public KeycloakJwtProperties properties() { + return new KeycloakJwtProperties(); + } + + @Bean + public HttpMessageConverter simplePidRecordConverter() { + return new SimplePidRecordConverter(); + } + + public static void main(String[] args) { + ConfigurableApplicationContext context = SpringApplication.run(Application.class, args); + System.out.println("Spring is running!"); + + final boolean cliArgsAmountValid = args != null && args.length != 0 && args.length >= 2; + + if (cliArgsAmountValid) { + ICliTask task = null; + Stream pidSource = null; + + if (Objects.equals(args[1], SOURCE_FROM_PREFIX)) { + try { + pidSource = PidSource.fromPrefix(context); + } catch (IOException e) { + e.printStackTrace(); + LOG.error(ERROR_COMMUNICATION, e.getMessage()); + exitApp(context, 1); + } + } else if (Objects.equals(args[1], SOURCE_KNOWN_PIDS)) { + pidSource = PidSource.fromKnown(context); + } + + if (Objects.equals(args[0], CMD_BOOTSTRAP)) { + task = new CliTaskBootstrap(context, pidSource); + } else if (Objects.equals(args[0], CMD_WRITE_FILE)) { + task = new CliTaskWriteFile(pidSource); + } + + try { + if (task != null && pidSource != null) { + // ---process task--- + if (task.process()) { + exitApp(context, 0); + } + } else { + printUsage(args); + exitApp(context, 1); + } + } catch (InvalidConfigException e) { + e.printStackTrace(); + LOG.error(ERROR_CONFIGURATION, e.getMessage()); + exitApp(context, 1); + } catch (IOException e) { + e.printStackTrace(); + LOG.error(ERROR_COMMUNICATION, e.getMessage()); + exitApp(context, 1); + } + } + } + + private static void printUsage(String[] args) { + String firstArg = args[0].replaceAll("[\r\n]",""); + String secondArg = args[1].replaceAll("[\r\n]",""); + LOG.error("Got commands: {} and {}", firstArg, secondArg); + LOG.error("CLI usage incorrect. Usage:"); + LOG.error("java -jar TypedPIDMaker.jar [ACTION] [SOURCE]"); + LOG.error("java -jar TypedPIDMaker.jar bootstrap all-pids-from-prefix"); + LOG.error("java -jar TypedPIDMaker.jar bootstrap known-pids"); + LOG.error("java -jar TypedPIDMaker.jar write-file all-pids-from-prefix"); + LOG.error("java -jar TypedPIDMaker.jar write-file known-pids"); + } + + private static void exitApp(ConfigurableApplicationContext context, int errCode) { + context.close(); + try { + Thread.sleep(2 * 1000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (errCode != 0) { + LOG.error("Exited with error."); + } else { + LOG.info("Success"); + } + System.exit(errCode); + } + +} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistry.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/DtrTest.java similarity index 95% rename from src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistry.java rename to src/main/java/edu/kit/datamanager/pit/typeregistry/impl/DtrTest.java index 8e552930..7fb6b999 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistry.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/DtrTest.java @@ -1,169 +1,167 @@ -package edu.kit.datamanager.pit.typeregistry.impl; - -import java.io.IOException; -import java.util.Date; -import java.util.List; -import java.util.Map; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.benmanes.caffeine.cache.AsyncLoadingCache; -import edu.kit.datamanager.pit.configuration.ApplicationProperties; -import edu.kit.datamanager.pit.domain.ProvenanceInformation; -import edu.kit.datamanager.pit.domain.TypeDefinition; -import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; -import java.net.URISyntaxException; -import java.time.Instant; -import java.time.format.DateTimeParseException; -import java.util.concurrent.*; -import java.util.stream.Collectors; -import java.util.stream.StreamSupport; - -import org.apache.commons.lang3.stream.Streams; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; - -/** - * Accessor for a specific instance of a TypeRegistry. The TypeRegistry is - * uniquely identified by a baseUrl and an identifierPrefix which all types of - * this particular registry are using. The prefix also allows to determine, - * whether a given PID might be a type or property registered at this - * TypeRegistry. - */ -public class TypeRegistry implements ITypeRegistry { - - private static final Logger LOG = LoggerFactory.getLogger(TypeRegistry.class); - protected static final Executor EXECUTOR = Executors.newWorkStealingPool(20); - - @Autowired - public AsyncLoadingCache typeCache; - @Autowired - private ApplicationProperties applicationProperties; - - protected RestTemplate restTemplate = new RestTemplate(); - - @Override - public TypeDefinition queryTypeDefinition(String typeIdentifier) throws IOException, URISyntaxException { - LOG.trace("Performing queryTypeDefinition({}).", typeIdentifier); - String[] segments = typeIdentifier.split("/"); - UriComponentsBuilder uriBuilder = UriComponentsBuilder - .fromUri( - applicationProperties - .getHandleBaseUri() - .toURI()) - .pathSegment(segments); - LOG.trace("Querying for type definition at URI {}.", uriBuilder); - ResponseEntity response = restTemplate.exchange(uriBuilder.build().toUri(), HttpMethod.GET, - HttpEntity.EMPTY, String.class); - ObjectMapper mapper = new ObjectMapper(); - JsonNode rootNode = mapper.readTree(response.getBody()); - LOG.trace("Constructing type definition from response."); - return constructTypeDefinition(rootNode); - } - - /** - * Helper method to construct a type definition from a JSON response - * received from the TypeRegistry. - * - * @param registryRepresentation The type definition. - * @return The TypeDefinition as object. - */ - private TypeDefinition constructTypeDefinition(JsonNode registryRepresentation) - throws JsonProcessingException, IOException, URISyntaxException { - // TODO We are doing things too complicated here. Deserialization should be - // easy. - // But before we change the domain model to do so, we need a lot of tests to - // make sure things work as before after the changes. - LOG.trace("Performing constructTypeDefinition()."); - final String identifier = registryRepresentation.path("identifier").asText(null); - if (identifier == null) { - LOG.error("No 'identifier' property found in entry: {}", registryRepresentation); - throw new IOException("No 'identifier' attribute found in type definition."); - } - - LOG.trace("Checking for 'properties' attribute."); - Map properties = new ConcurrentHashMap<>(); - List> propertiesHandling = Streams.stream(StreamSupport.stream( - registryRepresentation.path("properties").spliterator(), false)) - .filter(property -> property.hasNonNull("name")) - .filter(property -> property.hasNonNull("identifier")) - .map(property -> { - final String name = property.path("name").asText(); - final String pid = property.path("identifier").asText(); - return typeCache.get(pid).thenAcceptAsync( - typeDefinition -> { - final JsonNode semantics = property.path("representationsAndSemantics").path(0); - final String expression = semantics.path("expression").asText(null); - typeDefinition.setExpression(expression); - final String value = semantics.path("value").asText(null); - typeDefinition.setValue(value); - final String obligation = semantics.path("obligation").asText("Mandatory"); - typeDefinition.setOptional("Optional".equalsIgnoreCase(obligation)); - final String repeatable = semantics.path("repeatable").asText("No"); - typeDefinition.setRepeatable(!"No".equalsIgnoreCase(repeatable)); - properties.put(name, typeDefinition); - }, EXECUTOR); - }) - .collect(Collectors.toList()); - - TypeDefinition result = new TypeDefinition(); - result.setIdentifier(identifier); - final String description = registryRepresentation.path("description").asText(null); - result.setDescription(description); - final String name = registryRepresentation.path("name").asText(null); - result.setName(name); - final String validationSchema = registryRepresentation.path("validationSchema").asText(null); - result.setSchema(validationSchema); - - if (registryRepresentation.has("provenance")) { - ProvenanceInformation prov = new ProvenanceInformation(); - JsonNode provNode = registryRepresentation.get("provenance"); - if (provNode.has("creationDate")) { - String creationDate = provNode.get("creationDate").asText(); - try { - prov.setCreationDate(Date.from(Instant.parse(creationDate))); - } catch (DateTimeParseException ex) { - LOG.error("Failed to parse creationDate from value " + creationDate + ".", ex); - } - } - if (provNode.has("lastModificationDate")) { - String lastModificationDate = provNode.get("lastModificationDate").asText(); - try { - prov.setLastModificationDate(Date.from(Instant.parse(lastModificationDate))); - } catch (DateTimeParseException ex) { - LOG.error("Failed to parse lastModificationDate from value " + lastModificationDate + ".", ex); - } - } - for (JsonNode entryKV : provNode.get("contributors")) { - String identified = null; - String contributorName = null; - String details = null; - - if (registryRepresentation.has("identifiedBy")) { - identified = entryKV.get("identifiedBy").asText(); - } - if (registryRepresentation.has("name")) { - contributorName = entryKV.get("name").asText(); - } - if (registryRepresentation.has("details")) { - details = entryKV.get("details").asText(); - } - prov.addContributor(identified, contributorName, details); - } - result.setProvenance(prov); - } - - LOG.trace("Finalizing and returning type definition."); - CompletableFuture.allOf(propertiesHandling.toArray(new CompletableFuture[0])).join(); - properties.keySet().forEach(pd -> result.addSubType(properties.get(pd))); - this.typeCache.put(identifier, CompletableFuture.completedFuture(result)); - return result; - } -} +package edu.kit.datamanager.pit.typeregistry.impl; + +import java.io.IOException; +import java.util.Date; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import edu.kit.datamanager.pit.domain.ProvenanceInformation; +import edu.kit.datamanager.pit.domain.TypeDefinition; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; +import java.net.URISyntaxException; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.concurrent.*; +import java.util.stream.StreamSupport; + +import org.apache.commons.lang3.stream.Streams; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Accessor for a specific instance of a TypeRegistry. The TypeRegistry is + * uniquely identified by a baseUrl and an identifierPrefix which all types of + * this particular registry are using. The prefix also allows to determine, + * whether a given PID might be a type or property registered at this + * TypeRegistry. + */ +public class DtrTest implements ITypeRegistry { + + private static final Logger LOG = LoggerFactory.getLogger(DtrTest.class); + protected static final Executor EXECUTOR = Executors.newWorkStealingPool(20); + + @Autowired + public AsyncLoadingCache typeCache; + @Autowired + private ApplicationProperties applicationProperties; + + protected RestTemplate restTemplate = new RestTemplate(); + + @Override + public TypeDefinition queryTypeDefinition(String typeIdentifier) throws IOException, URISyntaxException { + LOG.trace("Performing queryTypeDefinition({}).", typeIdentifier); + String[] segments = typeIdentifier.split("/"); + UriComponentsBuilder uriBuilder = UriComponentsBuilder + .fromUri( + applicationProperties + .getHandleBaseUri() + .toURI()) + .pathSegment(segments); + LOG.trace("Querying for type definition at URI {}.", uriBuilder); + ResponseEntity response = restTemplate.exchange(uriBuilder.build().toUri(), HttpMethod.GET, + HttpEntity.EMPTY, String.class); + ObjectMapper mapper = new ObjectMapper(); + JsonNode rootNode = mapper.readTree(response.getBody()); + LOG.trace("Constructing type definition from response."); + return constructTypeDefinition(rootNode); + } + + /** + * Helper method to construct a type definition from a JSON response + * received from the TypeRegistry. + * + * @param registryRepresentation The type definition. + * @return The TypeDefinition as object. + */ + private TypeDefinition constructTypeDefinition(JsonNode registryRepresentation) + throws JsonProcessingException, IOException, URISyntaxException { + // TODO We are doing things too complicated here. Deserialization should be + // easy. + // But before we change the domain model to do so, we need a lot of tests to + // make sure things work as before after the changes. + LOG.trace("Performing constructTypeDefinition()."); + final String identifier = registryRepresentation.path("identifier").asText(null); + if (identifier == null) { + LOG.error("No 'identifier' property found in entry: {}", registryRepresentation); + throw new IOException("No 'identifier' attribute found in type definition."); + } + + LOG.trace("Checking for 'properties' attribute."); + Map properties = new ConcurrentHashMap<>(); + List> propertiesHandling = Streams.stream(StreamSupport.stream( + registryRepresentation.path("properties").spliterator(), false)) + .filter(property -> property.hasNonNull("name")) + .filter(property -> property.hasNonNull("identifier")) + .map(property -> { + final String name = property.path("name").asText(); + final String pid = property.path("identifier").asText(); + return typeCache.get(pid).thenAcceptAsync( + typeDefinition -> { + final JsonNode semantics = property.path("representationsAndSemantics").path(0); + final String expression = semantics.path("expression").asText(null); + typeDefinition.setExpression(expression); + final String value = semantics.path("value").asText(null); + typeDefinition.setValue(value); + final String obligation = semantics.path("obligation").asText("Mandatory"); + typeDefinition.setOptional("Optional".equalsIgnoreCase(obligation)); + final String repeatable = semantics.path("repeatable").asText("No"); + typeDefinition.setRepeatable(!"No".equalsIgnoreCase(repeatable)); + properties.put(name, typeDefinition); + }, EXECUTOR); + }) + .collect(Collectors.toList()); + + TypeDefinition result = new TypeDefinition(); + result.setIdentifier(identifier); + final String description = registryRepresentation.path("description").asText(null); + result.setDescription(description); + final String name = registryRepresentation.path("name").asText(null); + result.setName(name); + final String validationSchema = registryRepresentation.path("validationSchema").asText(null); + result.setSchema(validationSchema); + + if (registryRepresentation.has("provenance")) { + ProvenanceInformation prov = new ProvenanceInformation(); + JsonNode provNode = registryRepresentation.get("provenance"); + if (provNode.has("creationDate")) { + String creationDate = provNode.get("creationDate").asText(); + try { + prov.setCreationDate(Date.from(Instant.parse(creationDate))); + } catch (DateTimeParseException ex) { + LOG.error("Failed to parse creationDate from value " + creationDate + ".", ex); + } + } + if (provNode.has("lastModificationDate")) { + String lastModificationDate = provNode.get("lastModificationDate").asText(); + try { + prov.setLastModificationDate(Date.from(Instant.parse(lastModificationDate))); + } catch (DateTimeParseException ex) { + LOG.error("Failed to parse lastModificationDate from value " + lastModificationDate + ".", ex); + } + } + for (JsonNode entryKV : provNode.get("contributors")) { + String identified = null; + String contributorName = null; + String details = null; + + if (registryRepresentation.has("identifiedBy")) { + identified = entryKV.get("identifiedBy").asText(); + } + if (registryRepresentation.has("name")) { + contributorName = entryKV.get("name").asText(); + } + if (registryRepresentation.has("details")) { + details = entryKV.get("details").asText(); + } + prov.addContributor(identified, contributorName, details); + } + result.setProvenance(prov); + } + + LOG.trace("Finalizing and returning type definition."); + CompletableFuture.allOf(propertiesHandling.toArray(new CompletableFuture[0])).join(); + properties.keySet().forEach(pd -> result.addSubType(properties.get(pd))); + this.typeCache.put(identifier, CompletableFuture.completedFuture(result)); + return result; + } +} diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistryTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/DtrTestTest.java similarity index 96% rename from src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistryTest.java rename to src/test/java/edu/kit/datamanager/pit/typeregistry/impl/DtrTestTest.java index 2bb7db38..b25ba600 100644 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeRegistryTest.java +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/DtrTestTest.java @@ -18,10 +18,10 @@ // Set the in-memory implementation @TestPropertySource(locations = "/test/application-test.properties", properties = "pit.pidsystem.implementation = LOCAL") @ActiveProfiles("test") -class TypeRegistryTest { +class DtrTestTest { @Autowired - TypeRegistry typeRegistry; + DtrTest typeRegistry; final String profileIdentifier = "21.T11148/b9b76f887845e32d29f7"; From 439e8b0bdb3376fee9baa65ca2d32cd247f568c2 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 20 Nov 2024 18:44:14 +0100 Subject: [PATCH 005/108] feat: add main code base for type-api support --- config/application-default.properties | 10 +- config/application-docker.properties | 10 +- .../edu/kit/datamanager/pit/Application.java | 40 +--- .../configuration/ApplicationProperties.java | 41 +++- .../datamanager/pit/domain/ImmutableList.java | 10 + .../pit/typeregistry/AttributeInfo.java | 19 ++ .../pit/typeregistry/ITypeRegistry.java | 16 +- .../pit/typeregistry/RegisteredProfile.java | 51 +++++ .../RegisteredProfileAttribute.java | 21 ++ .../pit/typeregistry/impl/TypeApi.java | 202 ++++++++++++++++++ .../pit/typeregistry/impl/TypeApiTest.java | 16 ++ .../test/application-test.properties | 9 +- 12 files changed, 385 insertions(+), 60 deletions(-) create mode 100644 src/main/java/edu/kit/datamanager/pit/domain/ImmutableList.java create mode 100644 src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java create mode 100644 src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java create mode 100644 src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfileAttribute.java create mode 100644 src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java create mode 100644 src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java diff --git a/config/application-default.properties b/config/application-default.properties index b3aa28a2..5dab2e6d 100644 --- a/config/application-default.properties +++ b/config/application-default.properties @@ -169,11 +169,11 @@ pit.pidsystem.implementation = LOCAL pit.pidsystem.handle-protocol.handleRedirectAttributes = {'21.T11148/b8457812905b83046284'} ### Base URL for the DTR used. ### -# Currently, we support the DTRs of GWDG/ePIC. Currently known instances: -# - http://dtr-test.pidconsortium.eu/, https://dtr-test.pidconsortium.net/ -# - http://dtr-pit.pidconsortium.eu/, http://dtr-pit.pidconsortium.net/ -# - http://typeregistry.org/ -pit.typeregistry.baseURI = https://dtr-test.pidconsortium.eu/ +# Currently, we support the DTRs of GWDG/ePIC. +pit.typeregistry.baseURI = https://typeapi.lab.pidconsortium.net +# If the attribute(s) keys/types in your PID records are not being recognized as such, please contact us. +# As a workaround, add them to this list: +pit.validation.profileKeys = {} ### As this service is a RESTful serice without GUI, CSRF protection is not required. ### pit.security.enable-csrf: false diff --git a/config/application-docker.properties b/config/application-docker.properties index e6b3b654..90535d75 100644 --- a/config/application-docker.properties +++ b/config/application-docker.properties @@ -169,11 +169,11 @@ pit.pidsystem.implementation = LOCAL pit.pidsystem.handle-protocol.handleRedirectAttributes = {'21.T11148/b8457812905b83046284'} ### Base URL for the DTR used. ### -# Currently, we support the DTRs of GWDG/ePIC. Currently known instances: -# - http://dtr-test.pidconsortium.eu/, https://dtr-test.pidconsortium.net/ -# - http://dtr-pit.pidconsortium.eu/, http://dtr-pit.pidconsortium.net/ -# - http://typeregistry.org/ -pit.typeregistry.baseURI = https://dtr-test.pidconsortium.eu/ +# Currently, we support the DTRs of GWDG/ePIC. +pit.typeregistry.baseURI = https://typeapi.lab.pidconsortium.net +# If the attribute(s) keys/types in your PID records are not being recognized as such, please contact us. +# As a workaround, add them to this list: +pit.validation.profileKeys = {} ### As this service is a RESTful serice without GUI, CSRF protection is not required. ### pit.security.enable-csrf: false diff --git a/src/main/java/edu/kit/datamanager/pit/Application.java b/src/main/java/edu/kit/datamanager/pit/Application.java index 219118ac..3a19ceb5 100644 --- a/src/main/java/edu/kit/datamanager/pit/Application.java +++ b/src/main/java/edu/kit/datamanager/pit/Application.java @@ -19,9 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.github.benmanes.caffeine.cache.AsyncLoadingCache; -import com.github.benmanes.caffeine.cache.Caffeine; import edu.kit.datamanager.pit.cli.CliTaskBootstrap; import edu.kit.datamanager.pit.cli.CliTaskWriteFile; import edu.kit.datamanager.pit.cli.ICliTask; @@ -29,21 +27,18 @@ import edu.kit.datamanager.pit.common.InvalidConfigException; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; import edu.kit.datamanager.pit.pitservice.ITypingService; import edu.kit.datamanager.pit.pitservice.impl.TypingService; import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; -import edu.kit.datamanager.pit.typeregistry.impl.DtrTest; +import edu.kit.datamanager.pit.typeregistry.impl.TypeApi; import edu.kit.datamanager.pit.web.converter.SimplePidRecordConverter; import edu.kit.datamanager.security.filter.KeycloakJwtProperties; import java.io.IOException; -import java.time.Duration; import java.util.Objects; import java.util.concurrent.Executor; import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; import java.util.stream.Stream; import org.apache.http.client.HttpClient; @@ -103,13 +98,13 @@ public Logger logger(InjectionPoint injectionPoint) { } @Bean - public ITypeRegistry typeRegistry() { - return new DtrTest(); + public ITypeRegistry typeRegistry(ApplicationProperties props) { + return new TypeApi(props); } @Bean public ITypingService typingService(IIdentifierSystem identifierSystem, ApplicationProperties props) { - return new TypingService(identifierSystem, typeRegistry(), typeLoader(props)); + return new TypingService(identifierSystem, typeRegistry(props)); } @Bean(name = "OBJECT_MAPPER_BEAN") @@ -141,34 +136,7 @@ public CacheConfig cacheConfig() { .build(); } - /** - * This loader is a cache, which will retrieve `TypeDefinition`s, if required. - * - * Therefore, it can be used instead of the ITypeRegistry implementations. - * Retrieve it using Autowire or from the application context. - * - * @param props the applications properties set by the administration at the - * start of this application. - * @return the cache - */ @Bean - public AsyncLoadingCache typeLoader(ApplicationProperties props) { - int maximumSize = props.getMaximumSize(); - long expireAfterWrite = props.getExpireAfterWrite(); - return Caffeine.newBuilder() - .maximumSize(maximumSize) - .executor(EXECUTOR) - .refreshAfterWrite(Duration.ofMinutes(expireAfterWrite / 2)) - .expireAfterWrite(expireAfterWrite, TimeUnit.MINUTES) - .removalListener((key, value, cause) -> - LOG.trace("Removing type definition located at {} from schema cache. Cause: {}", key, cause) - ) - .buildAsync(pid -> { - LOG.trace("Loading type definition for identifier {} to cache.", pid); - return typeRegistry().queryTypeDefinition(pid); - }); - } - @ConfigurationProperties("pit") public ApplicationProperties applicationProperties() { return new ApplicationProperties(); diff --git a/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java b/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java index 6b313b03..18dd5f03 100644 --- a/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java +++ b/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java @@ -21,7 +21,10 @@ import edu.kit.datamanager.pit.pitservice.impl.NoValidationStrategy; import java.net.URL; +import java.util.List; +import java.util.Set; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import jakarta.validation.constraints.NotNull; import org.springframework.beans.factory.annotation.Value; @@ -46,6 +49,11 @@ @Validated public class ApplicationProperties extends GenericApplicationProperties { + private static Set KNOWN_PROFILE_KEYS = Set.of( + "21.T11148/076759916209e5d62bd5", + "21.T11969/bcc54a2a9ab5bf2a8f2c" + ); + public enum IdentifierSystemImpl { IN_MEMORY, LOCAL, @@ -66,10 +74,10 @@ public enum ValidationStrategy { private ValidationStrategy validationStrategy = ValidationStrategy.EMBEDDED_STRICT; @Bean - public IValidationStrategy defaultValidationStrategy() { + public IValidationStrategy defaultValidationStrategy(ITypeRegistry typeRegistry) { IValidationStrategy defaultStrategy = new NoValidationStrategy(); if (this.validationStrategy == ValidationStrategy.EMBEDDED_STRICT) { - defaultStrategy = new EmbeddedStrictValidatorStrategy(); + defaultStrategy = new EmbeddedStrictValidatorStrategy(typeRegistry, this); } return defaultStrategy; } @@ -108,8 +116,27 @@ public boolean storesResolved() { private long expireAfterWrite; @Value("${pit.validation.profileKey:21.T11148/076759916209e5d62bd5}") + @Deprecated(forRemoval = true /*In Typed PID Maker 3.0.0*/) private String profileKey; + @Value("${pit.validation.allowAdditionalAttributes:true}") + private boolean allowAdditionalAttributes = true; + + @Value("#{${pit.validation.profileKeys:{}}}") + @NotNull + protected List profileKeys = List.of(); + + public @NotNull Set getProfileKeys() { + Set allProfileKeys = new java.util.HashSet<>(Set.copyOf(KNOWN_PROFILE_KEYS)); + allProfileKeys.addAll(profileKeys); + allProfileKeys.add(this.getProfileKey()); + return allProfileKeys; + } + + public void setProfileKeys(@NotNull List profileKeys) { + this.profileKeys = profileKeys; + } + public IdentifierSystemImpl getIdentifierSystemImplementation() { return this.identifierSystemImplementation; } @@ -134,10 +161,12 @@ public void setTypeRegistryUri(URL typeRegistryUri) { this.typeRegistryUri = typeRegistryUri; } + @Deprecated(forRemoval = true) public String getProfileKey() { return this.profileKey; } + @Deprecated(forRemoval = true) public void setProfileKey(String profileKey) { this.profileKey = profileKey; } @@ -173,4 +202,12 @@ public StorageStrategy getStorageStrategy() { public void setStorageStrategy(StorageStrategy storageStrategy) { this.storageStrategy = storageStrategy; } + + public boolean isAllowAdditionalAttributes() { + return allowAdditionalAttributes; + } + + public void setAllowAdditionalAttributes(boolean allowAdditionalAttributes) { + this.allowAdditionalAttributes = allowAdditionalAttributes; + } } diff --git a/src/main/java/edu/kit/datamanager/pit/domain/ImmutableList.java b/src/main/java/edu/kit/datamanager/pit/domain/ImmutableList.java new file mode 100644 index 00000000..f2e2bd3a --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/domain/ImmutableList.java @@ -0,0 +1,10 @@ +package edu.kit.datamanager.pit.domain; + +import java.util.Collections; +import java.util.List; + +public record ImmutableList(List items) { + public ImmutableList { + items = Collections.unmodifiableList(items); + } +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java new file mode 100644 index 00000000..7940bdc2 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java @@ -0,0 +1,19 @@ +package edu.kit.datamanager.pit.typeregistry; + +import org.everit.json.schema.Schema; + +import java.util.List; + +/** + * @param pid the pid of this attribute + * @param name a human-readable name, defined in the DTR + * @param typeName name of the schema type of this attribute in the DTR, + * e.g. "Profile", "InfoType", "Special-Info-Type", ... + * @param jsonSchema the json schema to validate a value of this attribute + */ +public record AttributeInfo( + String pid, + String name, + String typeName, + Schema jsonSchema +) {} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/ITypeRegistry.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/ITypeRegistry.java index d5932e83..79d9eeef 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/ITypeRegistry.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/ITypeRegistry.java @@ -1,10 +1,6 @@ package edu.kit.datamanager.pit.typeregistry; -import java.io.IOException; - -import edu.kit.datamanager.pit.domain.TypeDefinition; - -import java.net.URISyntaxException; +import java.util.concurrent.CompletableFuture; /** * Main abstraction interface towards the type registry. Contains all methods @@ -12,13 +8,13 @@ * */ public interface ITypeRegistry { + CompletableFuture queryAttributeInfo(String attributePid); + CompletableFuture queryAsProfile(String profilePid); /** - * Queries a type definition record from the type registry. + * An identifier for exceptions and debugging purposes. * - * @param typeIdentifier - * @return a type definition record or null if the type is not registered. - * @throws IOException on communication errors with a remote registry + * @return a name ur url string that identifies the implementation or configuration well in case of errors. */ - public TypeDefinition queryTypeDefinition(String typeIdentifier) throws IOException, URISyntaxException; + String getRegistryIdentifier(); } diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java new file mode 100644 index 00000000..110de8b3 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java @@ -0,0 +1,51 @@ +package edu.kit.datamanager.pit.typeregistry; + +import edu.kit.datamanager.pit.common.RecordValidationException; +import edu.kit.datamanager.pit.domain.ImmutableList; +import edu.kit.datamanager.pit.domain.PIDRecord; + +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +public record RegisteredProfile( + String pid, + ImmutableList attributes +) { + + public void validateAttributes(PIDRecord pidRecord, boolean allowAdditionalAttributes) { + Set additionalAttributes = pidRecord.getPropertyIdentifiers().stream() + .filter(recordKey -> attributes.items().stream().anyMatch( + profileAttribute -> Objects.equals(profileAttribute.pid(), recordKey))) + .collect(Collectors.toSet()); + + boolean violatesAdditionalAttributes = !allowAdditionalAttributes && !additionalAttributes.isEmpty(); + if (violatesAdditionalAttributes) { + throw new RecordValidationException( + pidRecord, + String.format("Attributes %s are not allowed in profile %s", + String.join(", ", additionalAttributes), + this.pid) + ); + } + + for (RegisteredProfileAttribute profileAttribute : this.attributes.items()) { + if (profileAttribute.violatesMandatoryProperty(pidRecord)) { + throw new RecordValidationException( + pidRecord, + String.format("Attribute %s missing, but is mandatory in profile %s", + profileAttribute.pid(), + this.pid) + ); + } + if (profileAttribute.violatesRepeatableProperty(pidRecord)) { + throw new RecordValidationException( + pidRecord, + String.format("Attribute %s is not repeatable in profile %s, but has multiple values", + profileAttribute.pid(), + this.pid) + ); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfileAttribute.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfileAttribute.java new file mode 100644 index 00000000..5a85d940 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfileAttribute.java @@ -0,0 +1,21 @@ +package edu.kit.datamanager.pit.typeregistry; + +import edu.kit.datamanager.pit.common.RecordValidationException; +import edu.kit.datamanager.pit.domain.PIDRecord; + +public record RegisteredProfileAttribute( + String pid, + boolean mandatory, + boolean repeatable +) { + public boolean violatesMandatoryProperty(PIDRecord pidRecord) { + boolean contains = pidRecord.getPropertyIdentifiers().contains(this.pid) + && pidRecord.getPropertyValues(this.pid).length > 0; + return this.mandatory && !contains; + } + + public boolean violatesRepeatableProperty(PIDRecord pidRecord) { + boolean repeats = pidRecord.getPropertyValues(this.pid).length > 1; + return !this.repeatable && repeats; + } +} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java new file mode 100644 index 00000000..bdd2c4f5 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java @@ -0,0 +1,202 @@ +package edu.kit.datamanager.pit.typeregistry.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.github.benmanes.caffeine.cache.Caffeine; +import edu.kit.datamanager.pit.Application; +import edu.kit.datamanager.pit.common.ExternalServiceException; +import edu.kit.datamanager.pit.common.InvalidConfigException; +import edu.kit.datamanager.pit.common.TypeNotFoundException; +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import edu.kit.datamanager.pit.domain.ImmutableList; +import edu.kit.datamanager.pit.typeregistry.AttributeInfo; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; +import edu.kit.datamanager.pit.typeregistry.RegisteredProfile; +import edu.kit.datamanager.pit.typeregistry.RegisteredProfileAttribute; +import org.everit.json.schema.Schema; +import org.everit.json.schema.loader.SchemaLoader; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.client.RestClient; + +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +public class TypeApi implements ITypeRegistry { + + private static final Logger LOG = LoggerFactory.getLogger(TypeApi.class); + + protected final URL baseUrl; + protected final RestClient http; + protected final AsyncLoadingCache profileCache; + protected final AsyncLoadingCache attributeCache; + + public TypeApi(ApplicationProperties properties) { + this.baseUrl = properties.getTypeRegistryUri(); + String baseUri = null; + try { + baseUri = baseUrl.toURI().resolve("v1/types").toString(); + } catch (URISyntaxException e) { + throw new InvalidConfigException("Type-Api base url not valid: " + baseUrl); + } + this.http = RestClient.builder().baseUrl(baseUri).build(); + + // TODO better name caching properties (and consider extending them) + int maximumSize = properties.getMaximumSize(); + long expireAfterWrite = properties.getExpireAfterWrite(); + + this.profileCache = Caffeine.newBuilder() + .maximumSize(maximumSize) + .executor(Application.EXECUTOR) + .refreshAfterWrite(Duration.ofMinutes(expireAfterWrite / 2)) + .expireAfterWrite(expireAfterWrite, TimeUnit.MINUTES) + .removalListener((key, value, cause) -> + LOG.trace("Removing profile {} from profile cache. Cause: {}", key, cause) + ) + .buildAsync(maybeProfilePid -> { + LOG.trace("Loading profile {} to cache.", maybeProfilePid); + return this.queryProfile(maybeProfilePid); + }); + + this.attributeCache = Caffeine.newBuilder() + .maximumSize(maximumSize) + .executor(Application.EXECUTOR) + .refreshAfterWrite(Duration.ofMinutes(expireAfterWrite / 2)) + .expireAfterWrite(expireAfterWrite, TimeUnit.MINUTES) + .removalListener((key, value, cause) -> + LOG.trace("Removing profile {} from profile cache. Cause: {}", key, cause) + ) + .buildAsync(attributePid -> { + LOG.trace("Loading profile {} to cache.", attributePid); + return this.queryAttribute(attributePid); + }); + } + + private AttributeInfo queryAttribute(String attributePid) { + return http.get() + .uri(uriBuilder -> uriBuilder + .path(attributePid) + .build()) + .exchange((clientRequest, clientResponse) -> { + if (clientResponse.getStatusCode().is2xxSuccessful()) { + InputStream inputStream = clientResponse.getBody(); + String body = new String(inputStream.readAllBytes()); + inputStream.close(); + return extractAttributeInformation(attributePid, Application.jsonObjectMapper().readTree(body)); + } else { + throw new TypeNotFoundException(attributePid); + } + }); + } + + private AttributeInfo extractAttributeInformation(String attributePid, JsonNode jsonNode) { + String typeName = jsonNode.path("type").asText(); + String name = jsonNode.path("name").asText(); + Schema schema = this.querySchema(attributePid); + return new AttributeInfo(attributePid, name, typeName, schema); + } + + protected Schema querySchema(String maybeSchemaPid) throws TypeNotFoundException, ExternalServiceException { + return http.get() + .uri(uriBuilder -> uriBuilder + .pathSegment("schema") + .path(maybeSchemaPid) + .build()) + .exchange((clientRequest, clientResponse) -> { + if (clientResponse.getStatusCode().is2xxSuccessful()) { + InputStream inputStream = clientResponse.getBody(); + Schema schema; + try { + JSONObject rawSchema = new JSONObject(new JSONTokener(inputStream)); + schema = SchemaLoader.load(rawSchema); + } catch (JSONException e) { + throw new ExternalServiceException(baseUrl, "Response (" + maybeSchemaPid + ") is not a valid schema."); + } finally { + inputStream.close(); + } + return schema; + } else { + throw new TypeNotFoundException(maybeSchemaPid); + } + }); + } + + protected RegisteredProfile queryProfile(String maybeProfilePid) throws TypeNotFoundException, ExternalServiceException { + return http.get() + .uri(uriBuilder -> uriBuilder + .path(maybeProfilePid) + .build()) + .exchange((clientRequest, clientResponse) -> { + if (clientResponse.getStatusCode().is2xxSuccessful()) { + InputStream inputStream = clientResponse.getBody(); + String body = new String(inputStream.readAllBytes()); + inputStream.close(); + return extractProfileInformation(maybeProfilePid, Application.jsonObjectMapper().readTree(body)); + } else { + throw new TypeNotFoundException(maybeProfilePid); + } + }); + } + + protected RegisteredProfile extractProfileInformation(String profilePid, JsonNode typeApiResponse) + throws TypeNotFoundException, ExternalServiceException { + + List attributes = new ArrayList<>(); + typeApiResponse.path("content").path("properties").forEach(item -> { + + String attributePid = Optional.ofNullable(item.path("pid").asText(null)) + .or(() -> Optional.ofNullable(item.path("identifier").asText(null))) + .or(() -> Optional.ofNullable(item.path("id").asText())) + .orElse(""); + + JsonNode representations = item.path("representationsAndSemantics").path(0); + + JsonNode obligationNode = representations.path("obligation"); + boolean attributeMandatory = obligationNode.isBoolean() ? obligationNode.asBoolean() + : List.of("mandatory", "yes", "true").contains(obligationNode.asText().trim().toLowerCase()); + + JsonNode repeatableNode = representations.path("repeatable"); + boolean attributeRepeatable = repeatableNode.isBoolean() ? repeatableNode.asBoolean() + : List.of("yes", "true", "repeatable").contains(repeatableNode.asText().trim().toLowerCase()); + + RegisteredProfileAttribute attribute = new RegisteredProfileAttribute( + attributePid, + attributeMandatory, + attributeRepeatable); + + if (obligationNode.isNull() || repeatableNode.isNull() || attributePid.trim().isEmpty()) { + throw new ExternalServiceException(baseUrl, "Malformed attribute in profile (%s): " + attribute); + } + attributes.add(attribute); + + }); + + return new RegisteredProfile(profilePid, new ImmutableList<>(attributes)); + } + + @Override + public CompletableFuture queryAttributeInfo(String attributePid) { + return this.attributeCache.get(attributePid); + } + + @Override + public CompletableFuture queryAsProfile(String profilePid) { + return this.profileCache.get(profilePid); + } + + @Override + public String getRegistryIdentifier() { + return baseUrl; + } +} diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java new file mode 100644 index 00000000..42dd11aa --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java @@ -0,0 +1,16 @@ +package edu.kit.datamanager.pit.typeregistry.impl; + +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; + +import java.net.URI; +import java.net.URISyntaxException; + +import static org.junit.jupiter.api.Assertions.*; + +class TypeApiTest { + @Test + void dummy() throws URISyntaxException { + + } +} \ No newline at end of file diff --git a/src/test/resources/test/application-test.properties b/src/test/resources/test/application-test.properties index 3d789eee..68824c34 100644 --- a/src/test/resources/test/application-test.properties +++ b/src/test/resources/test/application-test.properties @@ -124,8 +124,13 @@ pit.pidsystem.handle.baseURI = https://hdl.handle.net/ #pit.pidsystem.handle.userPassword = password #pit.pidsystem.handle.generatorPrefix = 11043.4 -pit.typeregistry.baseURI = https://dtr-test.pidconsortium.eu/ -#http://typeregistry.org/registrar +### Base URL for the DTR used. ### +# Currently, we support the DTRs of GWDG/ePIC. +pit.typeregistry.baseURI = https://typeapi.lab.pidconsortium.net +# If the attribute(s) keys/types in your PID records are not being recognized as such, please contact us. +# As a workaround, add them to this list: +pit.validation.profileKeys = {} + pit.pidsystem.implementation = IN_MEMORY pit.validation.strategy:embedded-strict From 5ee3e4ad1fbbe95e581f56d2dd488aec65882a50 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 20 Nov 2024 18:53:36 +0100 Subject: [PATCH 006/108] feat: more flexible validation - support for records without profiles - support for records with multiple profiles - support for multiple profile attribute keys/types - support for additional attributes - in general, attribute validation and profile validation are now separate tasks --- README.md | 21 +++ .../pit/common/RecordValidationException.java | 5 + .../kit/datamanager/pit/domain/PIDRecord.java | 18 -- .../pit/domain/PIDRecordEntry.java | 4 - .../pit/domain/TypeDefinition.java | 103 ---------- .../impl/EmbeddedStrictValidatorStrategy.java | 177 ++++++------------ .../pit/typeregistry/impl/DtrTest.java | 167 ----------------- .../pit/util/TypeValidationUtils.java | 41 ---- .../pit/domain/TypeDefinitionTest.java | 63 ------- .../pit/typeregistry/impl/DtrTestTest.java | 49 ----- 10 files changed, 86 insertions(+), 562 deletions(-) delete mode 100644 src/main/java/edu/kit/datamanager/pit/domain/TypeDefinition.java delete mode 100644 src/main/java/edu/kit/datamanager/pit/typeregistry/impl/DtrTest.java delete mode 100644 src/main/java/edu/kit/datamanager/pit/util/TypeValidationUtils.java delete mode 100644 src/test/java/edu/kit/datamanager/pit/domain/TypeDefinitionTest.java delete mode 100644 src/test/java/edu/kit/datamanager/pit/typeregistry/impl/DtrTestTest.java diff --git a/README.md b/README.md index 9027d502..1c36208a 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,27 @@ PID = prefix + (branding + uniquely-generated-string) All other configuration properties affect only the `uniquely-generated-string`. For example, you may choose a different generation method (UUID (default) or Hex Chunks) enforce casing (lower-case, upper-case). +## What PID record validation means + +Our validation steps include the following: + +1. For each attribute, check if the values are valid according to their type specification. + - Values are considered to be in JSON format. If a type is complex, this implies a string-serialized JSON object. +2. For each attribute which indicates a profile, resolve the profile definitions from the values of the attributes. + - Attributes which indicates profiles are internally known, but may be added using the [configuration](https://github.com/kit-data-manager/pit-service/blob/master/config/application-default.properties). +3. For each profile definition, check if mandatory attributes are present. +4. For each profile definition, check if only repeatable attributes have multiple occurrences. + +This implies the following properties: + +- A profile is not required, + - but all profiles which are present are being used for validation, + - and all of them have to pass. +- Additional attributes are allowed if specified in the configuration of the Typed PID Maker instance. + - Otherwise, they are not allowed. Which makes it almost impossible to use multiple profiles. + - Only dtr-test supports an "additionalAttributesAllowed" boolean property per profile, + - But as it will not last and other DTRs do currently not support it, we don't support it either. + ## How to build > Note: Alternatively, you can use the docker image. diff --git a/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java b/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java index 520cc748..ab63aab0 100644 --- a/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java +++ b/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java @@ -28,6 +28,11 @@ public RecordValidationException(PIDRecord pidRecord, String reason) { this.pidRecord = pidRecord; } + public RecordValidationException(PIDRecord pidRecord, String reason, Exception e) { + super(HTTP_STATUS, VALIDATION_OF_RECORD + pidRecord.getPid() + " failed. Reason:\n" + reason, e); + this.pidRecord = pidRecord; + } + public PIDRecord getPidRecord() { return pidRecord; } diff --git a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java index 45629e31..d0a8f9db 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java @@ -157,24 +157,6 @@ public void removeAllValuesOf(String attribute) { this.entries.remove(attribute); } - /** - * Returns all missing mandatory attributes from the given Profile, which are not - * present in this record. - * - * @param profile the given Profile definition. - * @return all missing mandatory attributes. - */ - public Collection getMissingMandatoryTypesOf(TypeDefinition profile) { - Collection missing = new ArrayList<>(); - for (TypeDefinition td : profile.getSubTypes().values()) { - String typePid = td.getIdentifier(); - if (!td.isOptional() && !this.entries.containsKey(typePid)) { - missing.add(typePid); - } - } - return missing; - } - /** * Get all properties contained in this record. * diff --git a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java index e6a32cc0..dc7233b3 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java @@ -14,11 +14,7 @@ */ @Data public class PIDRecordEntry { - private String key; private String name; private String value; - - @JsonIgnore - private TypeDefinition resolvedTypeDefinition; } diff --git a/src/main/java/edu/kit/datamanager/pit/domain/TypeDefinition.java b/src/main/java/edu/kit/datamanager/pit/domain/TypeDefinition.java deleted file mode 100644 index b732db17..00000000 --- a/src/main/java/edu/kit/datamanager/pit/domain/TypeDefinition.java +++ /dev/null @@ -1,103 +0,0 @@ -/* SPDX-License-Identifier: Apache-2.0 */ - -package edu.kit.datamanager.pit.domain; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Set; -import lombok.Data; -import org.everit.json.schema.Schema; -import org.everit.json.schema.ValidationException; -import org.everit.json.schema.loader.SchemaLoader; -import org.json.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Representation of a type or profile definition in a data type registry. - * - * @author Thomas Jejkal - */ -@Data -@JsonIgnoreProperties(ignoreUnknown = true) -public class TypeDefinition { - - private static final Logger LOG = LoggerFactory.getLogger(TypeDefinition.class); - - private String name; - private String identifier; - private String description; - private boolean optional = false; - private boolean repeatable = false; - private String expression; - private String value; - private Schema jsonSchema; - - private ProvenanceInformation provenance; - @JsonProperty("properties") - private Map subTypes = new HashMap<>(); - - @JsonIgnore - private TypeDefinition resolvedTypeDefinition; - - @JsonIgnore - public Set getAllProperties() { - Set props = new HashSet<>(); - Set> entries = subTypes.entrySet(); - entries.forEach(entry -> props.add(entry.getKey())); - - return props; - } - - public void setSchema(String schema) { - if (schema == null) { - return; - } - - JSONObject jsonSchema = new JSONObject(schema); - this.jsonSchema = SchemaLoader.load(jsonSchema); - } - - /** - * Takes a value and validates it using this types JSON schema. - * - * @param document the value, usually taken from a PID record to be validated. - * @return true if the given value is valid accodting to this type. - */ - public boolean validate(String document) { - LOG.trace("Performing validate({}).", document); - if (jsonSchema != null) { - LOG.trace("Using schema-based validation."); - Object toValidate = document; - if (document.startsWith("{")) { - LOG.trace("Creating JSON object from provided value."); - toValidate = new JSONObject(document); - } - try { - LOG.trace("Validating provided value using type schema."); - jsonSchema.validate(toValidate); - LOG.trace("Validation successful."); - } catch (ValidationException ex) { - LOG.error("Validation failed.", ex); - return false; - } - } else { - LOG.trace("No schema available. Skipping validation."); - } - - return true; - } - - public boolean isOptional(String property) { - return subTypes.get(property).isOptional(); - } - - public void addSubType(TypeDefinition subType) { - subTypes.put(subType.getIdentifier(), subType); - } -} diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java index afc65d97..0d5a3f6c 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java @@ -1,23 +1,21 @@ package edu.kit.datamanager.pit.pitservice.impl; -import com.github.benmanes.caffeine.cache.AsyncLoadingCache; import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; import edu.kit.datamanager.pit.pitservice.IValidationStrategy; -import edu.kit.datamanager.pit.util.TypeValidationUtils; +import edu.kit.datamanager.pit.typeregistry.AttributeInfo; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import java.util.Arrays; import java.util.List; +import java.util.Set; import java.util.concurrent.*; -import java.util.stream.Collectors; -import org.apache.commons.lang3.stream.Streams; +import org.everit.json.schema.ValidationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; /** * Validates a PID record using embedded profile(s). @@ -31,55 +29,62 @@ public class EmbeddedStrictValidatorStrategy implements IValidationStrategy { private static final Logger LOG = LoggerFactory.getLogger(EmbeddedStrictValidatorStrategy.class); protected static final Executor EXECUTOR = Executors.newWorkStealingPool(); - @Autowired - public AsyncLoadingCache typeLoader; + protected ITypeRegistry typeRegistry; + protected boolean additionalAttributesAllowed; + protected Set profileKeys; - @Autowired - ApplicationProperties applicationProps; + public EmbeddedStrictValidatorStrategy(ITypeRegistry typeRegistry, ApplicationProperties config) { + this.typeRegistry = typeRegistry; + this.profileKeys = config.getProfileKeys(); + } @Override public void validate(PIDRecord pidRecord) throws RecordValidationException, ExternalServiceException { - String profileKey = applicationProps.getProfileKey(); - if (!pidRecord.hasProperty(profileKey)) { - throw new RecordValidationException( - pidRecord, - "Profile attribute not found. Expected key: " + profileKey); - } - String[] profilePIDs = pidRecord.getPropertyValues(profileKey); - boolean hasProfile = profilePIDs.length > 0; - if (!hasProfile) { - throw new RecordValidationException( - pidRecord, - "Profile attribute " + profileKey + " has no values."); + if (pidRecord.getPropertyIdentifiers().isEmpty()) { + throw new RecordValidationException(pidRecord, "Record is empty!"); } - List> futures = Streams.stream(Arrays.stream(profilePIDs)) - .map(profilePID -> { - try { - return this.typeLoader.get(profilePID) - .thenAcceptAsync(profileDefinition -> { - if (profileDefinition == null) { - LOG.error("No type definition found for identifier {}.", profilePID); - throw new RecordValidationException( - pidRecord, - String.format("No type found for identifier %s.", profilePID)); - } - this.strictProfileValidation(pidRecord, profileDefinition); - }, EXECUTOR); - } catch (RuntimeException e) { - LOG.error("Could not resolve identifier {}.", profilePID); - throw new ExternalServiceException( - applicationProps.getTypeRegistryUri().toString()); + // For each attribute in record, resolve schema and check the value + List> attributeInfoFutures = pidRecord.getPropertyIdentifiers().stream() + // resolve attribute info (type and schema) + .map(attributePid -> this.typeRegistry.queryAttributeInfo(attributePid)) + // validate values using schema + .map(attributeInfoFuture -> attributeInfoFuture.thenApply(attributeInfo -> { + for (String value : pidRecord.getPropertyValues(attributeInfo.pid())) { + try { + attributeInfo.jsonSchema().validate(value); + } catch (ValidationException e) { + throw new RecordValidationException( + pidRecord, + "Attribute %s has a non-complying value %s".formatted(attributeInfo.pid(), value), + e + ); + } + } + return attributeInfo; + })) + // resolve profiles and apply their validation + .map(attributeInfoFuture -> attributeInfoFuture.thenApply(attributeInfo -> { + boolean isProfile = this.profileKeys.contains(attributeInfo.pid()); + if (isProfile) { + Arrays.stream(pidRecord.getPropertyValues(attributeInfo.pid())) + .map(profilePid -> this.typeRegistry.queryAsProfile(profilePid)) + .forEach(registeredProfileFuture -> registeredProfileFuture.thenApply(registeredProfile -> { + registeredProfile.validateAttributes(pidRecord, this.additionalAttributesAllowed); + return registeredProfile; + })); } - }) - .collect(Collectors.toList()); + return attributeInfo; + })) + .toList(); + + try { - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + CompletableFuture.allOf(attributeInfoFutures.toArray(new CompletableFuture[0])).join(); } catch (CompletionException e) { throwRecordValidationExceptionCause(e); - throw new ExternalServiceException( - applicationProps.getTypeRegistryUri().toString()); + throw new ExternalServiceException(this.typeRegistry.getRegistryIdentifier().toString()); } catch (CancellationException e) { throwRecordValidationExceptionCause(e); throw new RecordValidationException( @@ -88,81 +93,19 @@ public void validate(PIDRecord pidRecord) throws RecordValidationException, Exte } } - private static void throwRecordValidationExceptionCause(Throwable e) { - if (e.getCause() instanceof RecordValidationException rve) { - throw rve; - } - } - /** - * Exceptions indicate failure. No Exceptions mean success. - * - * @param pidRecord the PID record to validate. - * @param profile the profile to validate against. - * @throws RecordValidationException with error message on validation errors. + * Checks Exceptions' causes for a RecordValidationExceptions, and throws them, if present. + *

+ * Usually used to avoid exposing exceptions related to futures. + * @param e the exception to unwrap. */ - private void strictProfileValidation(PIDRecord pidRecord, TypeDefinition profile) throws RecordValidationException { - // if (profile.hasSchema()) { - // TODO issue https://github.com/kit-data-manager/pit-service/issues/104 - // validate using schema and you are done (strict validation) - // String jsonRecord = ""; // TODO format depends on schema source - // return profile.validate(jsonRecord); - // } - - LOG.trace("Validating PID record against profile {}.", profile.getIdentifier()); - - TypeValidationUtils.checkMandatoryAttributes(pidRecord, profile); - - for (String attributeKey : pidRecord.getPropertyIdentifiers()) { - LOG.trace("Checking PID record key {}.", attributeKey); - - TypeDefinition type = profile.getSubTypes().get(attributeKey); - if (type == null) { - LOG.error("No sub-type found for key {}.", attributeKey); - // TODO try to resolve it (for later when we support "allow additional - // attributes") - // if profile.allowsAdditionalAttributes() {...} else - throw new RecordValidationException( - pidRecord, - String.format("Attribute %s is not allowed in profile %s", - attributeKey, - profile.getIdentifier())); - } - - validateValuesForKey(pidRecord, attributeKey, type); - } - LOG.debug("successfully validated {}", profile.getIdentifier()); - } - - /** - * Validates all values of an attribute against a given type definition. - * - * @param pidRecord the record containing the attribute and value. - * @param attributeKey the attribute to check the values for. - * @param type the type definition to check against. - * @throws RecordValidationException on error. - */ - private void validateValuesForKey(PIDRecord pidRecord, String attributeKey, TypeDefinition type) - throws RecordValidationException { - String[] values = pidRecord.getPropertyValues(attributeKey); - for (String value : values) { - if (value == null) { - LOG.error("'null' record value found for key {}.", attributeKey); - throw new RecordValidationException( - pidRecord, - String.format("Validation of value %s against type %s failed.", - value, - type.getIdentifier())); - } - - if (!type.validate(value)) { - LOG.error("Validation of value {} against type {} failed.", value, type.getIdentifier()); - throw new RecordValidationException( - pidRecord, - String.format("Validation of value %s against type %s failed.", - value, - type.getIdentifier())); - } + private static void throwRecordValidationExceptionCause(Throwable e) { + Throwable cause = e.getCause(); + if (cause instanceof RecordValidationException rve) { + throw rve; + } else if (cause != null && cause.getCause() instanceof RecordValidationException rve) { + // in some cases we need to go deeper, because profiles are handled in a future within a future. + throw rve; } } } diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/DtrTest.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/DtrTest.java deleted file mode 100644 index 7fb6b999..00000000 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/DtrTest.java +++ /dev/null @@ -1,167 +0,0 @@ -package edu.kit.datamanager.pit.typeregistry.impl; - -import java.io.IOException; -import java.util.Date; -import java.util.Map; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.benmanes.caffeine.cache.AsyncLoadingCache; -import edu.kit.datamanager.pit.configuration.ApplicationProperties; -import edu.kit.datamanager.pit.domain.ProvenanceInformation; -import edu.kit.datamanager.pit.domain.TypeDefinition; -import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; -import java.net.URISyntaxException; -import java.time.Instant; -import java.time.format.DateTimeParseException; -import java.util.concurrent.*; -import java.util.stream.StreamSupport; - -import org.apache.commons.lang3.stream.Streams; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; - -/** - * Accessor for a specific instance of a TypeRegistry. The TypeRegistry is - * uniquely identified by a baseUrl and an identifierPrefix which all types of - * this particular registry are using. The prefix also allows to determine, - * whether a given PID might be a type or property registered at this - * TypeRegistry. - */ -public class DtrTest implements ITypeRegistry { - - private static final Logger LOG = LoggerFactory.getLogger(DtrTest.class); - protected static final Executor EXECUTOR = Executors.newWorkStealingPool(20); - - @Autowired - public AsyncLoadingCache typeCache; - @Autowired - private ApplicationProperties applicationProperties; - - protected RestTemplate restTemplate = new RestTemplate(); - - @Override - public TypeDefinition queryTypeDefinition(String typeIdentifier) throws IOException, URISyntaxException { - LOG.trace("Performing queryTypeDefinition({}).", typeIdentifier); - String[] segments = typeIdentifier.split("/"); - UriComponentsBuilder uriBuilder = UriComponentsBuilder - .fromUri( - applicationProperties - .getHandleBaseUri() - .toURI()) - .pathSegment(segments); - LOG.trace("Querying for type definition at URI {}.", uriBuilder); - ResponseEntity response = restTemplate.exchange(uriBuilder.build().toUri(), HttpMethod.GET, - HttpEntity.EMPTY, String.class); - ObjectMapper mapper = new ObjectMapper(); - JsonNode rootNode = mapper.readTree(response.getBody()); - LOG.trace("Constructing type definition from response."); - return constructTypeDefinition(rootNode); - } - - /** - * Helper method to construct a type definition from a JSON response - * received from the TypeRegistry. - * - * @param registryRepresentation The type definition. - * @return The TypeDefinition as object. - */ - private TypeDefinition constructTypeDefinition(JsonNode registryRepresentation) - throws JsonProcessingException, IOException, URISyntaxException { - // TODO We are doing things too complicated here. Deserialization should be - // easy. - // But before we change the domain model to do so, we need a lot of tests to - // make sure things work as before after the changes. - LOG.trace("Performing constructTypeDefinition()."); - final String identifier = registryRepresentation.path("identifier").asText(null); - if (identifier == null) { - LOG.error("No 'identifier' property found in entry: {}", registryRepresentation); - throw new IOException("No 'identifier' attribute found in type definition."); - } - - LOG.trace("Checking for 'properties' attribute."); - Map properties = new ConcurrentHashMap<>(); - List> propertiesHandling = Streams.stream(StreamSupport.stream( - registryRepresentation.path("properties").spliterator(), false)) - .filter(property -> property.hasNonNull("name")) - .filter(property -> property.hasNonNull("identifier")) - .map(property -> { - final String name = property.path("name").asText(); - final String pid = property.path("identifier").asText(); - return typeCache.get(pid).thenAcceptAsync( - typeDefinition -> { - final JsonNode semantics = property.path("representationsAndSemantics").path(0); - final String expression = semantics.path("expression").asText(null); - typeDefinition.setExpression(expression); - final String value = semantics.path("value").asText(null); - typeDefinition.setValue(value); - final String obligation = semantics.path("obligation").asText("Mandatory"); - typeDefinition.setOptional("Optional".equalsIgnoreCase(obligation)); - final String repeatable = semantics.path("repeatable").asText("No"); - typeDefinition.setRepeatable(!"No".equalsIgnoreCase(repeatable)); - properties.put(name, typeDefinition); - }, EXECUTOR); - }) - .collect(Collectors.toList()); - - TypeDefinition result = new TypeDefinition(); - result.setIdentifier(identifier); - final String description = registryRepresentation.path("description").asText(null); - result.setDescription(description); - final String name = registryRepresentation.path("name").asText(null); - result.setName(name); - final String validationSchema = registryRepresentation.path("validationSchema").asText(null); - result.setSchema(validationSchema); - - if (registryRepresentation.has("provenance")) { - ProvenanceInformation prov = new ProvenanceInformation(); - JsonNode provNode = registryRepresentation.get("provenance"); - if (provNode.has("creationDate")) { - String creationDate = provNode.get("creationDate").asText(); - try { - prov.setCreationDate(Date.from(Instant.parse(creationDate))); - } catch (DateTimeParseException ex) { - LOG.error("Failed to parse creationDate from value " + creationDate + ".", ex); - } - } - if (provNode.has("lastModificationDate")) { - String lastModificationDate = provNode.get("lastModificationDate").asText(); - try { - prov.setLastModificationDate(Date.from(Instant.parse(lastModificationDate))); - } catch (DateTimeParseException ex) { - LOG.error("Failed to parse lastModificationDate from value " + lastModificationDate + ".", ex); - } - } - for (JsonNode entryKV : provNode.get("contributors")) { - String identified = null; - String contributorName = null; - String details = null; - - if (registryRepresentation.has("identifiedBy")) { - identified = entryKV.get("identifiedBy").asText(); - } - if (registryRepresentation.has("name")) { - contributorName = entryKV.get("name").asText(); - } - if (registryRepresentation.has("details")) { - details = entryKV.get("details").asText(); - } - prov.addContributor(identified, contributorName, details); - } - result.setProvenance(prov); - } - - LOG.trace("Finalizing and returning type definition."); - CompletableFuture.allOf(propertiesHandling.toArray(new CompletableFuture[0])).join(); - properties.keySet().forEach(pd -> result.addSubType(properties.get(pd))); - this.typeCache.put(identifier, CompletableFuture.completedFuture(result)); - return result; - } -} diff --git a/src/main/java/edu/kit/datamanager/pit/util/TypeValidationUtils.java b/src/main/java/edu/kit/datamanager/pit/util/TypeValidationUtils.java deleted file mode 100644 index 5b062727..00000000 --- a/src/main/java/edu/kit/datamanager/pit/util/TypeValidationUtils.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package edu.kit.datamanager.pit.util; - -import edu.kit.datamanager.pit.common.RecordValidationException; -import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; - -import java.util.Collection; - -/** - * Utility class with static functions to validate PID records. - * - * @author Thomas Jejkal - */ -public class TypeValidationUtils { - - private TypeValidationUtils() {} - - /** - * Check if all mandatory attributes are present. If not, throw an exception. - * - * @param pidRecord the record to check for - * @param profile the profile to check against - * @throws RecordValidationException if at least one attribute is missing. It - * shows all missing attributes in its error - * message. - */ - public static void checkMandatoryAttributes(PIDRecord pidRecord, TypeDefinition profile) - throws RecordValidationException { - Collection missing = pidRecord.getMissingMandatoryTypesOf(profile); - if (!missing.isEmpty()) { - throw new RecordValidationException( - pidRecord, - "Missing mandatory types: " + missing); - } - } -} diff --git a/src/test/java/edu/kit/datamanager/pit/domain/TypeDefinitionTest.java b/src/test/java/edu/kit/datamanager/pit/domain/TypeDefinitionTest.java deleted file mode 100644 index a7db33f2..00000000 --- a/src/test/java/edu/kit/datamanager/pit/domain/TypeDefinitionTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package edu.kit.datamanager.pit.domain; - -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -@Disabled("Does not work yet due to the complexity of the TypeDefinition implementation. See TODO below.") -class TypeDefinitionTest { - - @Test - // TODO We should change the domain model so this or similar tests will run. - // But before we change the domain model to do so, we need a lot of tests to make sure things work as before after the changes. - // Currently deserialization is done in `TypeRegistry.constructTypeDefinition` in a very complicated way. - void deserialization() throws JsonMappingException, JsonProcessingException { - String type = "{\n" - + " \"identifier\": \"21.T11148/1c699a5d1b4ad3ba4956\",\n" - + " \"name\": \"digitalObjectType\",\n" - + " \"description\": \"Handle points to type definition in DTR for this type of object. Distinguishing metadata from data objects is a client decision within a particular usage context, which may to some extent rely on the digitalObjectType value provided. (context : KernelInformation)\\n\",\n" - + " \"standards\": [{\n" - + " \"natureOfApplicability\": \"depends\",\n" - + " \"name\": \"21.T11148/3626040cadcac1571685\",\n" - + " \"issuer\": \"DTR\"\n" - + " }],\n" - + " \"provenance\": {\n" - + " \"contributors\": [{\n" - + " \"identifiedUsing\": \"Text\",\n" - + " \"name\": \"Ulrich Schwardmann\",\n" - + " \"details\": \"GWDG\"\n" - + " }],\n" - + " \"creationDate\": \"2019-04-01T11:01:52.469Z\",\n" - + " \"lastModificationDate\": \"2019-11-14T12:28:19.011Z\"\n" - + " },\n" - + " \"representationsAndSemantics\": [{\n" - + " \"expression\": \"\",\n" - + " \"value\": \"\",\n" - + " \"subSchemaRelation\": \"denyAdditionalProperties\",\n" - + " \"allowAbbreviatedForm\": \"Yes\"\n" - + " }],\n" - + " \"properties\": [{\n" - + " \"name\": \"digitalObjectType\",\n" - + " \"identifier\": \"21.T11148/3626040cadcac1571685\",\n" - + " \"representationsAndSemantics\": [{\n" - + " \"expression\": \"\",\n" - + " \"value\": \"\",\n" - + " \"obligation\": \"Mandatory\",\n" - + " \"repeatable\": \"No\",\n" - + " \"allowOmitSubsidiaries\": \"Yes\"\n" - + " }]\n" - + " }],\n" - + " \"validationSchema\": \"{\\\"definitions\\\": {\\\"21.T11148_3626040cadcac1571685\\\": {\\\"pattern\\\": \\\"^([0-9,A-Z,a-z])+(\\\\\\\\.([0-9,A-Z,a-z])+)*\\\\\\\\/([!-~])+$\\\", \\\"type\\\": \\\"string\\\", \\\"description\\\": \\\"Handle-Identifier-ASCII@21.T11148/3626040cadcac1571685\\\"}}, \\\"$schema\\\": \\\"http://json-schema.org/draft-04/schema#\\\", \\\"description\\\": \\\"digitalObjectType@21.T11148/1c699a5d1b4ad3ba4956\\\", \\\"$ref\\\": \\\"#/definitions/21.T11148_3626040cadcac1571685\\\"}\"\n" - + "}"; - - ObjectMapper mapper = new ObjectMapper(); - TypeDefinition def = mapper.readValue(type, TypeDefinition.class); - System.out.println("DEF " + def.getExpression()); - assertNotNull(def); - } -} diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/DtrTestTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/DtrTestTest.java deleted file mode 100644 index b25ba600..00000000 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/DtrTestTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package edu.kit.datamanager.pit.typeregistry.impl; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.net.URISyntaxException; - -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.TestPropertySource; - -// JUnit5 + Spring -@SpringBootTest -// Set the in-memory implementation -@TestPropertySource(locations = "/test/application-test.properties", properties = "pit.pidsystem.implementation = LOCAL") -@ActiveProfiles("test") -class DtrTestTest { - - @Autowired - DtrTest typeRegistry; - - final String profileIdentifier = "21.T11148/b9b76f887845e32d29f7"; - - /** - * See if it does not only cache the sub-types but also the profile itself. - * - * @throws URISyntaxException - * @throws IOException - */ - @Test - void isCachingProfiles() throws IOException, URISyntaxException { - assertEquals( - null, - typeRegistry.typeCache.getIfPresent(profileIdentifier)); - assertEquals(0, typeRegistry.typeCache.synchronous().estimatedSize()); - - typeRegistry.queryTypeDefinition(profileIdentifier); - assertNotEquals( - null, - typeRegistry.typeCache.getIfPresent(profileIdentifier)); - // A profile definition contains type definitions. - // The cache therefore should have more than one identifiers in cache. - assertTrue(typeRegistry.typeCache.synchronous().estimatedSize() > 1); - } -} From dabaa4dd12aba15bb6c93449220af9ca63ec6ed6 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 20 Nov 2024 18:54:07 +0100 Subject: [PATCH 007/108] feat: use virtual threads for async execution --- src/main/java/edu/kit/datamanager/pit/Application.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/edu/kit/datamanager/pit/Application.java b/src/main/java/edu/kit/datamanager/pit/Application.java index 3a19ceb5..3b867aae 100644 --- a/src/main/java/edu/kit/datamanager/pit/Application.java +++ b/src/main/java/edu/kit/datamanager/pit/Application.java @@ -87,7 +87,7 @@ public class Application { protected static final String ERROR_COMMUNICATION = "Communication error: {}"; protected static final String ERROR_CONFIGURATION = "Configuration error: {}"; - protected static final Executor EXECUTOR = Executors.newWorkStealingPool(); + public static final Executor EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); @Bean From 3ff56e44147d93ddcea22b6ab200077ba0b3d806 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 20 Nov 2024 19:04:17 +0100 Subject: [PATCH 008/108] cleanup: remove unused methods which depended on the old TypeDefinition class --- .../datamanager/pit/cli/CliTaskBootstrap.java | 2 +- .../datamanager/pit/domain/Operations.java | 56 ++++---- .../pit/pidsystem/IIdentifierSystem.java | 51 ++----- .../pidsystem/impl/HandleProtocolAdapter.java | 54 +------ .../impl/InMemoryIdentifierSystem.java | 38 +---- .../pidsystem/impl/local/LocalPidSystem.java | 39 +----- .../pit/pitservice/ITypingService.java | 49 +------ .../pit/pitservice/impl/TypingService.java | 132 +++--------------- .../pit/web/impl/TypingRESTResourceImpl.java | 12 +- .../pidsystem/IIdentifierSystemQueryTest.java | 43 +++--- .../pidsystem/IIdentifierSystemWriteTest.java | 4 +- .../impl/InMemoryIdentifierSystemTest.java | 47 +------ .../impl/local/LocalPidSystemTest.java | 57 ++------ .../web/ExplicitValidationParametersTest.java | 14 +- .../pit/web/RestWithInMemoryTest.java | 7 +- .../pit/web/RestWithLocalPidSystemTest.java | 7 +- 16 files changed, 138 insertions(+), 474 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/cli/CliTaskBootstrap.java b/src/main/java/edu/kit/datamanager/pit/cli/CliTaskBootstrap.java index 2abe28d8..1e0d7e23 100644 --- a/src/main/java/edu/kit/datamanager/pit/cli/CliTaskBootstrap.java +++ b/src/main/java/edu/kit/datamanager/pit/cli/CliTaskBootstrap.java @@ -77,7 +77,7 @@ public boolean process() throws IOException, InvalidConfigException { // store in Elasticsearch elastic.ifPresent(elastic -> { try { - PIDRecord rec = typingService.queryAllProperties(known.getPid()); + PIDRecord rec = typingService.queryPid(known.getPid()); LOG.info("Store PID {} in Elasticsearch.", known.getPid()); PidRecordElasticWrapper wrapper = new PidRecordElasticWrapper(rec, typingService.getOperations()); elastic.save(wrapper); diff --git a/src/main/java/edu/kit/datamanager/pit/domain/Operations.java b/src/main/java/edu/kit/datamanager/pit/domain/Operations.java index 94f5f128..95246d83 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/Operations.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/Operations.java @@ -11,13 +11,14 @@ import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; +import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; +import edu.kit.datamanager.pit.typeregistry.AttributeInfo; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import org.apache.commons.lang3.stream.Streams; import org.joda.time.DateTime; import org.joda.time.format.DateTimeFormatter; import org.joda.time.format.ISODateTimeFormat; -import edu.kit.datamanager.pit.pitservice.ITypingService; - /** * Simple operations on PID records. * @@ -34,10 +35,12 @@ public class Operations { "21.T11148/397d831aa3a9d18eb52c" }; - private ITypingService typingService; + private ITypeRegistry typeRegistry; + private IIdentifierSystem identifierSystem; - public Operations(ITypingService typingService) { - this.typingService = typingService; + public Operations(ITypeRegistry typeRegistry, IIdentifierSystem identifierSystem) { + this.typeRegistry = typeRegistry; + this.identifierSystem = identifierSystem; } /** @@ -70,15 +73,13 @@ public Optional findDateCreated(PIDRecord pidRecord) throws IOException { return date; } - /* TODO try to find types extending or relating otherwise to known types - * (currently not supported by our TypeDefinition) */ - Collection types = new ArrayList<>(); - List> futures = Streams - .stream(pidRecord.getPropertyIdentifiers().stream()) - .filter(attributePid -> this.typingService.isIdentifierRegistered(attributePid)) + Collection types = new ArrayList<>(); + List> futures = Streams.failableStream( + pidRecord.getPropertyIdentifiers().stream()) + .filter(attributePid -> this.identifierSystem.isPidRegistered(attributePid)) .map(attributePid -> { - return this.typingService - .describeType(attributePid) + return this.typeRegistry + .queryAttributeInfo(attributePid) .thenAcceptAsync(types::add); }) .collect(Collectors.toList()); @@ -95,10 +96,10 @@ public Optional findDateCreated(PIDRecord pidRecord) throws IOException { return types .stream() .filter(type -> - type.getName().equals("dateCreated") - || type.getName().equals("createdAt") - || type.getName().equals("creationDate")) - .map(type -> pidRecord.getPropertyValues(type.getIdentifier())) + type.name().equalsIgnoreCase("dateCreated") + || type.name().equalsIgnoreCase("createdAt") + || type.name().equalsIgnoreCase("creationDate")) + .map(type -> pidRecord.getPropertyValues(type.pid())) .map(Arrays::asList) .flatMap(List::stream) .map(this::extractDate) @@ -138,15 +139,12 @@ public Optional findDateModified(PIDRecord pidRecord) throws IOException { return date; } - /* TODO try to find types extending or relating otherwise to known types - * (currently not supported by our TypeDefinition) */ - Collection types = new ArrayList<>(); - List> futures = Streams - .stream(pidRecord.getPropertyIdentifiers().stream()) - .filter(attributePid -> this.typingService.isIdentifierRegistered(attributePid)) + Collection types = new ArrayList<>(); + List> futures = Streams.failableStream(pidRecord.getPropertyIdentifiers().stream()) + .filter(attributePid -> this.identifierSystem.isPidRegistered(attributePid)) .map(attributePid -> { - return this.typingService - .describeType(attributePid) + return this.typeRegistry + .queryAttributeInfo(attributePid) .thenAcceptAsync(types::add); }) .collect(Collectors.toList()); @@ -163,10 +161,10 @@ public Optional findDateModified(PIDRecord pidRecord) throws IOException { return types .stream() .filter(type -> - type.getName().equals("dateModified") - || type.getName().equals("lastModified") - || type.getName().equals("modificationDate")) - .map(type -> pidRecord.getPropertyValues(type.getIdentifier())) + type.name().equalsIgnoreCase("dateModified") + || type.name().equalsIgnoreCase("lastModified") + || type.name().equalsIgnoreCase("modificationDate")) + .map(type -> pidRecord.getPropertyValues(type.pid())) .map(Arrays::asList) .flatMap(List::stream) .map(this::extractDate) diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystem.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystem.java index 8adad1e1..a12eece6 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystem.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystem.java @@ -6,7 +6,6 @@ import edu.kit.datamanager.pit.common.PidNotFoundException; import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; import edu.kit.datamanager.pit.pidgeneration.PidSuffix; import java.util.Collection; @@ -57,7 +56,7 @@ public default String appendPrefixIfAbsent(String pid) throws InvalidConfigExcep * @throws ExternalServiceException on commonication errors or errors on other * services. */ - public boolean isIdentifierRegistered(String pid) throws ExternalServiceException; + public boolean isPidRegistered(String pid) throws ExternalServiceException; /** * Checks whether the given PID is already registered. @@ -73,9 +72,9 @@ public default String appendPrefixIfAbsent(String pid) throws InvalidConfigExcep * @throws InvalidConfigException if there is no prefix configured to append to * the suffix. */ - public default boolean isIdentifierRegistered(PidSuffix suffix) throws ExternalServiceException, InvalidConfigException { + public default boolean isPidRegistered(PidSuffix suffix) throws ExternalServiceException, InvalidConfigException { String prefix = getPrefix().orElseThrow(() -> new InvalidConfigException("This system cannot create PIDs.")); - return isIdentifierRegistered(suffix.getWithPrefix(prefix)); + return isPidRegistered(suffix.getWithPrefix(prefix)); } /** @@ -89,27 +88,14 @@ public default boolean isIdentifierRegistered(PidSuffix suffix) throws ExternalS * @throws ExternalServiceException on commonication errors or errors on other * services. */ - public PIDRecord queryAllProperties(String pid) throws PidNotFoundException, ExternalServiceException; - - /** - * Queries a single property from the given PID. - * - * @param pid the PID to query from. - * @param typeDefinition the type to query. - * @return the property value or null if there is no property of given name - * defined in this PID record. - * @throws PidNotFoundException if PID is not registered. - * @throws ExternalServiceException if an error occured in communication with - * other services. - */ - public String queryProperty(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException; + public PIDRecord queryPid(String pid) throws PidNotFoundException, ExternalServiceException; /** * Registers a new PID with given property values. The method takes the PID from * the record and treats it as a suffix. * * The method must process the given PID using the - * {@link #registerPID(PIDRecord)} method. + * {@link #registerPid(PIDRecord)} method. * * @param pidRecord contains the initial PID record. * @return the PID that was assigned to the record. @@ -118,7 +104,7 @@ public default boolean isIdentifierRegistered(PidSuffix suffix) throws ExternalS * other services. * @throws RecordValidationException if record validation errors occurred. */ - public default String registerPID(final PIDRecord pidRecord) throws PidAlreadyExistsException, ExternalServiceException, RecordValidationException { + public default String registerPid(final PIDRecord pidRecord) throws PidAlreadyExistsException, ExternalServiceException, RecordValidationException { if (pidRecord.getPid() == null) { throw new RecordValidationException(pidRecord, "PID must not be null."); } @@ -133,7 +119,7 @@ public default String registerPID(final PIDRecord pidRecord) throws PidAlreadyEx /** * Registers the given record with its given PID, without applying any checks. - * Recommended to use {@link #registerPID(PIDRecord)} instead. + * Recommended to use {@link #registerPid(PIDRecord)} instead. * * As an implementor, you can assume the PID to be not null, valid, * non-registered, and prefixed. @@ -157,26 +143,7 @@ public default String registerPID(final PIDRecord pidRecord) throws PidAlreadyEx * other services. * @throws RecordValidationException if record validation errors occurred. */ - public boolean updatePID(PIDRecord pidRecord) throws PidNotFoundException, ExternalServiceException, RecordValidationException; - - /** - * Queries all properties of a given type available from the given PID. If - * optional properties are present, they will be returned as well. If there are - * mandatory properties missing (i.e. the record of the given PID does not fully - * conform to the type), the method will NOT fail but simply return only those - * properties that are present. - * - * @param pid the PID to query the type from. - * @param typeDefinition the type to query. - * @return a PID information record with property identifiers mapping to values. - * The property names will not be available (empty Strings). Contains - * all property values present in the record of the given PID that are - * also specified by the type (mandatory or optional). - * @throws PidNotFoundException if the pid is not registered. - * @throws ExternalServiceException if an error occured in communication with - * other services. - */ - public PIDRecord queryByType(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException; + public boolean updatePid(PIDRecord pidRecord) throws PidNotFoundException, ExternalServiceException, RecordValidationException; /** * Remove the given PID. @@ -187,7 +154,7 @@ public default String registerPID(final PIDRecord pidRecord) throws PidAlreadyEx * @param pid the PID to delete. * @return true if the identifier was deleted, false if it did not exist. */ - public boolean deletePID(String pid) throws ExternalServiceException; + public boolean deletePid(String pid) throws ExternalServiceException; /** * Returns all PIDs which are registered for the configured prefix. diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java index ff76086c..ea8b633e 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java @@ -10,7 +10,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.Map.Entry; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -35,7 +34,6 @@ import edu.kit.datamanager.pit.configuration.HandleProtocolProperties; import edu.kit.datamanager.pit.domain.PIDRecord; import edu.kit.datamanager.pit.domain.PIDRecordEntry; -import edu.kit.datamanager.pit.domain.TypeDefinition; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; import net.handle.api.HSAdapter; import net.handle.api.HSAdapterFactory; @@ -149,8 +147,8 @@ public Optional getPrefix() { } @Override - public boolean isIdentifierRegistered(final String pid) throws ExternalServiceException { - HandleValue[] recordProperties = null; + public boolean isPidRegistered(final String pid) throws ExternalServiceException { + HandleValue[] recordProperties; try { recordProperties = this.client.resolveHandle(pid, null, null); } catch (HandleException e) { @@ -164,7 +162,7 @@ public boolean isIdentifierRegistered(final String pid) throws ExternalServiceEx } @Override - public PIDRecord queryAllProperties(final String pid) throws PidNotFoundException, ExternalServiceException { + public PIDRecord queryPid(final String pid) throws PidNotFoundException, ExternalServiceException { Collection allValues = this.queryAllHandleValues(pid); if (allValues.isEmpty()) { return null; @@ -191,26 +189,6 @@ protected Collection queryAllHandleValues(final String pid) throws } } - @Override - public String queryProperty(final String pid, final TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { - String[] typeArray = { typeDefinition.getIdentifier() }; - try { - // TODO we assume here that the property only exists once, which will not be - // true in every case. - // The interface likely should be adjusted so we can return all types and do not - // need to return a String. - return this.client.resolveHandle(pid, typeArray, null)[0].getDataAsString(); - } catch (HandleException e) { - if (e.getCode() == HandleException.INVALID_VALUE) { - return null; - } else if (e.getCode() == HandleException.HANDLE_DOES_NOT_EXIST) { - throw new PidNotFoundException(pid); - } else { - throw new ExternalServiceException(SERVICE_NAME_HANDLE, e); - } - } - } - @Override public String registerPidUnchecked(final PIDRecord pidRecord) throws PidAlreadyExistsException, ExternalServiceException { // Add admin value for configured user only @@ -239,7 +217,7 @@ public String registerPidUnchecked(final PIDRecord pidRecord) throws PidAlreadyE } @Override - public boolean updatePID(final PIDRecord pidRecord) throws PidNotFoundException, ExternalServiceException, RecordValidationException { + public boolean updatePid(final PIDRecord pidRecord) throws PidNotFoundException, ExternalServiceException, RecordValidationException { if (!this.isValidPID(pidRecord.getPid())) { return false; } @@ -305,27 +283,7 @@ public boolean updatePID(final PIDRecord pidRecord) throws PidNotFoundException, } @Override - public PIDRecord queryByType(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { - PIDRecord allProps = queryAllProperties(pid); - if (allProps == null) { - return null; - } - // only return properties listed in the type definition - Set typeProps = typeDefinition.getAllProperties(); - PIDRecord result = new PIDRecord(); - for (String propID : allProps.getPropertyIdentifiers()) { - if (typeProps.contains(propID)) { - String[] values = allProps.getPropertyValues(propID); - for (String value : values) { - result.addEntry(propID, "", value); - } - } - } - return result; - } - - @Override - public boolean deletePID(final String pid) throws ExternalServiceException { + public boolean deletePid(final String pid) throws ExternalServiceException { try { this.client.deleteHandle(pid); } catch (HandleException e) { @@ -493,7 +451,7 @@ protected boolean isValidPID(final String pid) { return false; } try { - if (!this.isIdentifierRegistered(pid)) { + if (!this.isPidRegistered(pid)) { return false; } } catch (ExternalServiceException e) { diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystem.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystem.java index 664b8aaa..87e180f4 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystem.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystem.java @@ -4,7 +4,6 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.stream.Collectors; import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.common.InvalidConfigException; @@ -13,7 +12,6 @@ import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; import org.slf4j.Logger; @@ -47,23 +45,15 @@ public Optional getPrefix() { } @Override - public boolean isIdentifierRegistered(String pid) throws ExternalServiceException { + public boolean isPidRegistered(String pid) throws ExternalServiceException { return this.records.containsKey(pid); } @Override - public PIDRecord queryAllProperties(String pid) throws PidNotFoundException, ExternalServiceException { - PIDRecord pidRecord = this.records.get(pid); - if (pidRecord == null) { return null; } - return pidRecord; - } - - @Override - public String queryProperty(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { + public PIDRecord queryPid(String pid) throws PidNotFoundException, ExternalServiceException { PIDRecord pidRecord = this.records.get(pid); if (pidRecord == null) { throw new PidNotFoundException(pid); } - if (!pidRecord.hasProperty(typeDefinition.getIdentifier())) { return null; } - return pidRecord.getPropertyValue(typeDefinition.getIdentifier()); + return pidRecord; } @Override @@ -74,7 +64,7 @@ public String registerPidUnchecked(final PIDRecord pidRecord) throws PidAlreadyE } @Override - public boolean updatePID(PIDRecord record) throws PidNotFoundException, ExternalServiceException, RecordValidationException { + public boolean updatePid(PIDRecord record) throws PidNotFoundException, ExternalServiceException, RecordValidationException { if (this.records.containsKey(record.getPid())) { this.records.put(record.getPid(), record); return true; @@ -83,25 +73,7 @@ public boolean updatePID(PIDRecord record) throws PidNotFoundException, External } @Override - public PIDRecord queryByType(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { - PIDRecord allProps = this.queryAllProperties(pid); - if (allProps == null) {return null;} - // only return properties listed in the type def - Set typeProps = typeDefinition.getAllProperties(); - PIDRecord result = new PIDRecord(); - for (String propID : allProps.getPropertyIdentifiers()) { - if (typeProps.contains(propID)) { - String[] values = allProps.getPropertyValues(propID); - for (String value : values) { - result.addEntry(propID, "", value); - } - } - } - return result; - } - - @Override - public boolean deletePID(String pid) { + public boolean deletePid(String pid) { throw new UnsupportedOperationException("Deleting PIDs is against the P in PID."); } diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystem.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystem.java index bb388ef3..8190eb13 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystem.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystem.java @@ -11,7 +11,6 @@ import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; import org.slf4j.Logger; @@ -80,24 +79,14 @@ public Optional getPrefix() { } @Override - public boolean isIdentifierRegistered(String pid) throws ExternalServiceException { + public boolean isPidRegistered(String pid) throws ExternalServiceException { return this.db.existsById(pid); } @Override - public PIDRecord queryAllProperties(String pid) throws PidNotFoundException, ExternalServiceException { + public PIDRecord queryPid(String pid) throws PidNotFoundException, ExternalServiceException { Optional dbo = this.db.findByPid(pid); - if (dbo.isEmpty()) { return null; } - return new PIDRecord(dbo.get()); - } - - @Override - public String queryProperty(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { - Optional dbo = this.db.findByPid(pid); - if (dbo.isEmpty()) { throw new PidNotFoundException(pid); } - PIDRecord rec = new PIDRecord(dbo.get()); - if (!rec.hasProperty(typeDefinition.getIdentifier())) { return null; } - return rec.getPropertyValue(typeDefinition.getIdentifier()); + return new PIDRecord(dbo.orElseThrow(() -> new PidNotFoundException(pid))); } @Override @@ -111,7 +100,7 @@ public String registerPidUnchecked(final PIDRecord pidRecord) throws PidAlreadyE } @Override - public boolean updatePID(PIDRecord rec) throws PidNotFoundException, ExternalServiceException, RecordValidationException { + public boolean updatePid(PIDRecord rec) throws PidNotFoundException, ExternalServiceException, RecordValidationException { if (this.db.existsById(rec.getPid())) { this.db.save(new PidDatabaseObject(rec)); return true; @@ -120,25 +109,7 @@ public boolean updatePID(PIDRecord rec) throws PidNotFoundException, ExternalSer } @Override - public PIDRecord queryByType(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { - PIDRecord allProps = this.queryAllProperties(pid); - if (allProps == null) {return null;} - // only return properties listed in the type def - Set typeProps = typeDefinition.getAllProperties(); - PIDRecord result = new PIDRecord(); - for (String propID : allProps.getPropertyIdentifiers()) { - if (typeProps.contains(propID)) { - String[] values = allProps.getPropertyValues(propID); - for (String value : values) { - result.addEntry(propID, "", value); - } - } - } - return result; - } - - @Override - public boolean deletePID(String pid) { + public boolean deletePid(String pid) { throw new UnsupportedOperationException("Deleting PIDs is against the P in PID."); } diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/ITypingService.java b/src/main/java/edu/kit/datamanager/pit/pitservice/ITypingService.java index aafceb7c..ef5e44ff 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/ITypingService.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/ITypingService.java @@ -4,9 +4,6 @@ import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.domain.Operations; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; -import java.io.IOException; -import java.util.concurrent.CompletableFuture; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; @@ -22,53 +19,9 @@ public interface ITypingService extends IIdentifierSystem { public void validate(PIDRecord pidRecord) throws RecordValidationException, ExternalServiceException; - /** - * Retrieves a type definition - * - * @param typeIdentifier - * @return null if there is no type with given identifier, the definition - * record otherwise. - * @throws IOException - */ - public CompletableFuture describeType(String typeIdentifier) throws IOException; - - /** - * Queries a single property from the PID. - * - * @param pid - * @param propertyIdentifier must be registered in the type registry - * @return a PIDRecord object containing the single property name and - * value or null if the property is undefined. - * @throws IOException - * @throws IllegalArgumentException if the property is defined but ambiguous - * (type registry query returned multiple results). - */ - public PIDRecord queryProperty(String pid, String propertyIdentifier) throws IOException; - - public PIDRecord queryAllProperties(String pid, boolean includePropertyNames) throws IOException; - - /** - * Queries all properties of a type available from the given PID. If - * optional properties are present, they will be returned as well. If there - * are mandatory properties missing (i.e. the record of the given PID does - * not fully conform to the type), the method will NOT fail but simply - * return only those properties that are present. - * - * @param pid - * @param typeIdentifier a type identifier, not a name - * @param includePropertyNames if true, the method will also return property - * names at additional call costs. - * @return a PID information record with property identifiers mapping to - * values. Contains all property values present in the record of the given - * PID that are also specified by the type (mandatory or optional). If the - * pid is not registered, the method returns null. - * @throws IOException - */ - public PIDRecord queryByType(String pid, String typeIdentifier, boolean includePropertyNames) throws IOException; - /** * Returns an operations instance, configured with this typingService. - * + *

* Convenience method for `new Operations(typingService)`. * * @return an operation instance. diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/TypingService.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/TypingService.java index 4a77b054..d9a2fc99 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/TypingService.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/TypingService.java @@ -1,26 +1,24 @@ package edu.kit.datamanager.pit.pitservice.impl; -import com.github.benmanes.caffeine.cache.AsyncLoadingCache; import edu.kit.datamanager.pit.common.InvalidConfigException; import edu.kit.datamanager.pit.common.PidAlreadyExistsException; import edu.kit.datamanager.pit.common.PidNotFoundException; import edu.kit.datamanager.pit.common.RecordValidationException; -import java.io.IOException; import java.util.Collection; import java.util.Optional; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; +import edu.kit.datamanager.pit.typeregistry.AttributeInfo; import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import edu.kit.datamanager.pit.pitservice.ITypingService; import edu.kit.datamanager.pit.pitservice.IValidationStrategy; import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.domain.Operations; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletionException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,8 +35,6 @@ public class TypingService implements ITypingService { private static final String LOG_MSG_TYPING_SERVICE_MISCONFIGURED = "Typing service misconfigured."; private static final String LOG_MSG_QUERY_TYPE = "Querying for type with identifier {}."; - - protected final AsyncLoadingCache typeCache; protected final IIdentifierSystem identifierSystem; protected final ITypeRegistry typeRegistry; @@ -52,12 +48,10 @@ public class TypingService implements ITypingService { @Autowired protected IValidationStrategy defaultStrategy = null; - public TypingService(IIdentifierSystem identifierSystem, ITypeRegistry typeRegistry, - AsyncLoadingCache typeCache) { + public TypingService(IIdentifierSystem identifierSystem, ITypeRegistry typeRegistry) { super(); this.identifierSystem = identifierSystem; this.typeRegistry = typeRegistry; - this.typeCache = typeCache; } @Override @@ -77,15 +71,9 @@ public void validate(PIDRecord pidRecord) } @Override - public boolean isIdentifierRegistered(String pid) throws ExternalServiceException { + public boolean isPidRegistered(String pid) throws ExternalServiceException { LOG.trace("Performing isIdentifierRegistered({}).", pid); - return identifierSystem.isIdentifierRegistered(pid); - } - - @Override - public String queryProperty(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { - LOG.trace("Performing queryProperty({}, TypeDefinition#{}).", pid, typeDefinition.getIdentifier()); - return identifierSystem.queryProperty(pid, typeDefinition); + return identifierSystem.isPidRegistered(pid); } @Override @@ -95,47 +83,20 @@ public String registerPidUnchecked(final PIDRecord pidRecord) throws PidAlreadyE } @Override - public PIDRecord queryByType(String pid, TypeDefinition typeDefinition) throws PidNotFoundException, ExternalServiceException { - LOG.trace("Performing queryByType({}, TypeDefinition#{}).", pid, typeDefinition.getIdentifier()); - return identifierSystem.queryByType(pid, typeDefinition); - } - - @Override - public boolean deletePID(String pid) throws ExternalServiceException { + public boolean deletePid(String pid) throws ExternalServiceException { LOG.trace("Performing deletePID({}).", pid); - return identifierSystem.deletePID(pid); - } - - @Override - public CompletableFuture describeType(String typeIdentifier) throws IOException { - LOG.trace("Performing describeType({}).", typeIdentifier); - try { - LOG.trace(LOG_MSG_QUERY_TYPE, typeIdentifier); - return typeCache.get(typeIdentifier); - } catch (RuntimeException ex) { - LOG.error("Failed to query for type with identifier " + typeIdentifier + ".", ex); - throw new InvalidConfigException(LOG_MSG_TYPING_SERVICE_MISCONFIGURED); - } + return identifierSystem.deletePid(pid); } @Override - public PIDRecord queryAllProperties(String pid) throws PidNotFoundException, ExternalServiceException { - LOG.trace("Performing queryAllProperties({}).", pid); - PIDRecord pidRecord = identifierSystem.queryAllProperties(pid); - if (pidRecord == null) { - throw new PidNotFoundException(pid); - } - // ensure the PID is always contained - pidRecord.setPid(pid); - return pidRecord; + public PIDRecord queryPid(String pid) throws PidNotFoundException, ExternalServiceException { + return queryPid(pid, false); } - @Override - public PIDRecord queryAllProperties(String pid, boolean includePropertyNames) - throws IOException { + public PIDRecord queryPid(String pid, boolean includePropertyNames) + throws PidNotFoundException, ExternalServiceException { LOG.trace("Performing queryAllProperties({}, {}).", pid, includePropertyNames); - PIDRecord pidInfo = identifierSystem.queryAllProperties(pid); - LOG.trace("PID record found. {}", (includePropertyNames) ? "Adding property names." : "Returning result."); + PIDRecord pidInfo = identifierSystem.queryPid(pid); if (includePropertyNames) { enrichPIDInformationRecord(pidInfo); @@ -143,40 +104,20 @@ public PIDRecord queryAllProperties(String pid, boolean includePropertyNames) return pidInfo; } - @Override - public PIDRecord queryProperty(String pid, String propertyIdentifier) throws IOException { - LOG.trace("Performing queryProperty({}, {}).", pid, propertyIdentifier); - PIDRecord pidInfo = new PIDRecord(); - // query type registry - TypeDefinition typeDef; - try { - LOG.trace(LOG_MSG_QUERY_TYPE, propertyIdentifier); - typeDef = typeCache.get(propertyIdentifier).get(); - } catch (ExecutionException | InterruptedException ex) { - LOG.error(LOG_MSG_QUERY_TYPE, propertyIdentifier); - throw new InvalidConfigException(LOG_MSG_TYPING_SERVICE_MISCONFIGURED); - } - - if (typeDef != null) { - pidInfo.addEntry(propertyIdentifier, typeDef.getName(), identifierSystem.queryProperty(pid, typeDef)); - return pidInfo; - } - return null; - } - private void enrichPIDInformationRecord(PIDRecord pidInfo) { // enrich record by querying type registry for all property definitions // to get the property names for (String typeIdentifier : pidInfo.getPropertyIdentifiers()) { - TypeDefinition typeDef; + AttributeInfo attributeInfo; try { - typeDef = typeCache.get(typeIdentifier).get(); - } catch (ExecutionException | InterruptedException ex) { + attributeInfo = this.typeRegistry.queryAttributeInfo(typeIdentifier).join(); + } catch (CompletionException | CancellationException ex) { + // TODO convert exceptions like in validation service. throw new InvalidConfigException(LOG_MSG_TYPING_SERVICE_MISCONFIGURED); } - if (typeDef != null) { - pidInfo.setPropertyName(typeIdentifier, typeDef.getName()); + if (attributeInfo != null) { + pidInfo.setPropertyName(typeIdentifier, attributeInfo.name()); } else { pidInfo.setPropertyName(typeIdentifier, typeIdentifier); } @@ -184,37 +125,8 @@ private void enrichPIDInformationRecord(PIDRecord pidInfo) { } @Override - public PIDRecord queryByType(String pid, String typeIdentifier, boolean includePropertyNames) - throws IOException { - TypeDefinition typeDef; - try { - typeDef = typeCache.get(typeIdentifier).get(); - } catch (ExecutionException | InterruptedException ex) { - throw new InvalidConfigException(LOG_MSG_TYPING_SERVICE_MISCONFIGURED); - } - - if (typeDef == null) { - return null; - } - // now query PID record and fill in information based on property keys in type definition - PIDRecord result = identifierSystem.queryByType(pid, typeDef); - if (includePropertyNames) { - enrichPIDInformationRecord(result); - } - return result; - } - - public ITypeRegistry getTypeRegistry() { - return typeRegistry; - } - - public IIdentifierSystem getIdentifierSystem() { - return identifierSystem; - } - - @Override - public boolean updatePID(PIDRecord pidRecord) throws PidNotFoundException, ExternalServiceException, RecordValidationException { - return this.identifierSystem.updatePID(pidRecord); + public boolean updatePid(PIDRecord pidRecord) throws PidNotFoundException, ExternalServiceException, RecordValidationException { + return this.identifierSystem.updatePid(pidRecord); } @Override @@ -223,7 +135,7 @@ public Collection resolveAllPidsOfPrefix() throws ExternalServiceExcepti } public Operations getOperations() { - return new Operations(this); + return new Operations(this.typeRegistry, this.identifierSystem); } } diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index b8d97dca..1212f72a 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -104,7 +104,7 @@ public ResponseEntity createPID( return ResponseEntity.status(HttpStatus.OK).eTag(quotedEtag(pidRecord)).body(pidRecord); } - String pid = this.typingService.registerPID(pidRecord); + String pid = this.typingService.registerPid(pidRecord); pidRecord.setPid(pid); if (applicationProps.getStorageStrategy().storesModified()) { @@ -138,7 +138,7 @@ private void setPid(PIDRecord pidRecord) throws IOException { String prefix = this.typingService.getPrefix().orElseThrow(() -> new IOException("No prefix configured.")); String maybeSuffix = pidRecord.getPid(); String pid = PidSuffix.asPrefixedChecked(maybeSuffix, prefix); - boolean isRegisteredPid = this.typingService.isIdentifierRegistered(pid); + boolean isRegisteredPid = this.typingService.isPidRegistered(pid); if (isRegisteredPid) { throw new PidAlreadyExistsException(pidRecord.getPid()); } @@ -150,7 +150,7 @@ private void setPid(PIDRecord pidRecord) throws IOException { Stream suffixStream = suffixGenerator.infiniteStream(); Optional maybeSuffix = Streams.failableStream(suffixStream) // With failible streams, we can throw exceptions. - .filter(suffix -> !this.typingService.isIdentifierRegistered(suffix)) + .filter(suffix -> !this.typingService.isPidRegistered(suffix)) .stream() // back to normal java streams .findFirst(); // as the stream is infinite, we should always find a prefix. PidSuffix suffix = maybeSuffix.orElseThrow(() -> new IOException("Could not generate PID suffix.")); @@ -173,7 +173,7 @@ public ResponseEntity updatePID( "PID in record was given, but it was not the same as the PID in the URL. Ignore request, assuming this was not intended."); } - PIDRecord existingRecord = this.typingService.queryAllProperties(pid); + PIDRecord existingRecord = this.typingService.queryPid(pid); if (existingRecord == null) { throw new PidNotFoundException(pid); } @@ -185,7 +185,7 @@ public ResponseEntity updatePID( this.typingService.validate(pidRecord); // update and send message - if (this.typingService.updatePID(pidRecord)) { + if (this.typingService.updatePid(pidRecord)) { // store pid locally if (applicationProps.getStorageStrategy().storesModified()) { storeLocally(pidRecord.getPid(), true); @@ -242,7 +242,7 @@ public ResponseEntity getRecord( final UriComponentsBuilder uriBuilder ) throws IOException { String pid = getContentPathFromRequest("pid", request); - PIDRecord pidRecord = this.typingService.queryAllProperties(pid); + PIDRecord pidRecord = this.typingService.queryPid(pid); if (applicationProps.getStorageStrategy().storesResolved()) { storeLocally(pid, false); } diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java index db1744f4..78142ff4 100644 --- a/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java @@ -15,7 +15,6 @@ import edu.kit.datamanager.pit.common.PidNotFoundException; import edu.kit.datamanager.pit.configuration.HandleProtocolProperties; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; import net.handle.hdllib.HandleException; @@ -52,7 +51,7 @@ private static Stream implProvider() throws HandleException, IOExcept ); IIdentifierSystem inMemory = new InMemoryIdentifierSystem(); - String inMemoryPid = inMemory.registerPID(rec); + String inMemoryPid = inMemory.registerPid(rec); // TODO initiate REST impl @@ -64,20 +63,20 @@ private static Stream implProvider() throws HandleException, IOExcept @ParameterizedTest @MethodSource("implProvider") - public void isIdentifierRegisteredTrue(IIdentifierSystem impl, String pid) throws IOException { - assertTrue(impl.isIdentifierRegistered(pid)); + public void isPidRegisteredTrue(IIdentifierSystem impl, String pid) throws IOException { + assertTrue(impl.isPidRegistered(pid)); } @ParameterizedTest @MethodSource("implProvider") - public void isIdentifierRegisteredFalse(IIdentifierSystem impl, String pid, String pid_nonexist) throws IOException { - assertFalse(impl.isIdentifierRegistered(pid_nonexist)); + public void isPidRegisteredFalse(IIdentifierSystem impl, String pid, String pid_nonexist) throws IOException { + assertFalse(impl.isPidRegistered(pid_nonexist)); } @ParameterizedTest @MethodSource("implProvider") - public void queryAllPropertiesExample(IIdentifierSystem impl, String pid) throws IOException { - PIDRecord result = impl.queryAllProperties(pid); + public void queryPidExample(IIdentifierSystem impl, String pid) throws IOException { + PIDRecord result = impl.queryPid(pid); assertEquals(result.getPid(), pid); assertTrue(result.getPropertyIdentifiers().contains("10320/loc")); assertFalse(result.getPropertyIdentifiers().contains("HS_ADMIN")); @@ -85,40 +84,34 @@ public void queryAllPropertiesExample(IIdentifierSystem impl, String pid) throws @ParameterizedTest @MethodSource("implProvider") - public void queryAllPropertiesOfNonexistent(IIdentifierSystem impl, String _pid, String pid_nonexist) throws IOException { - PIDRecord result = impl.queryAllProperties(pid_nonexist); + public void queryPidOfNonexistent(IIdentifierSystem impl, String _pid, String pid_nonexist) throws IOException { + PIDRecord result = impl.queryPid(pid_nonexist); assertNull(result); } @ParameterizedTest @MethodSource("implProvider") public void querySingleProperty(IIdentifierSystem impl, String pid) throws IOException { - TypeDefinition type = new TypeDefinition(); - type.setIdentifier("10320/loc"); - type.setDescription("FakeType for testing. Actually describing the location in some handle specific format, and no registered type"); - String property = impl.queryProperty(pid, type); - assertTrue(property.contains("objects/21.T11148/076759916209e5d62bd5\" weight=\"1\" view=\"json\"")); - assertTrue(property.contains("#objects/21.T11148/076759916209e5d62bd5\" weight=\"0\" view=\"ui\"")); + PIDRecord record = impl.queryPid(pid); + String attributeKey = "10320/loc"; + assertTrue(record.getPropertyIdentifiers().contains(attributeKey)); + String value = record.getPropertyValue(attributeKey); + assertTrue(value.contains("objects/21.T11148/076759916209e5d62bd5\" weight=\"1\" view=\"json\"")); + assertTrue(value.contains("#objects/21.T11148/076759916209e5d62bd5\" weight=\"0\" view=\"ui\"")); } @ParameterizedTest @MethodSource("implProvider") public void queryNonexistentProperty(IIdentifierSystem impl, String pid) throws IOException { - TypeDefinition type = new TypeDefinition(); - type.setIdentifier("Nonexistent_Property"); - type.setDescription("FakeType for testing. Does not exist and query should fail somehow."); - String property = impl.queryProperty(pid, type); - assertNull(property); + PIDRecord record = impl.queryPid(pid); + assertFalse(record.getPropertyIdentifiers().contains("Nonexistent_Attribute")); } @ParameterizedTest @MethodSource("implProvider") public void queryPropertyOfNonexistent(IIdentifierSystem impl, String pid, String pid_nonexist) throws IOException { - TypeDefinition type = new TypeDefinition(); - type.setIdentifier("Nonexistent_Property"); - type.setDescription("FakeType for testing. Does not exist and query should fail somehow."); assertThrows(PidNotFoundException.class, () -> { - impl.queryProperty(pid_nonexist, type); + impl.queryPid(pid_nonexist); }); } } diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemWriteTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemWriteTest.java index 86fd2c1c..f315595a 100644 --- a/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemWriteTest.java +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemWriteTest.java @@ -62,7 +62,7 @@ void setup() throws InterruptedException, IOException { String value = pidGenerator.generate().getWithPrefix(PID_PREFIX); r.addEntry(attribute, "test", value); - localPidSystem.registerPID(r); + localPidSystem.registerPid(r); } @Test @@ -76,6 +76,6 @@ void testExtensiveRecordWithLocalPidSystem() throws IOException { assertEquals(numAttributes, r.getPropertyIdentifiers().size()); assertEquals(numValues, r.getPropertyValues(r.getPropertyIdentifiers().iterator().next()).length); - this.localPidSystem.registerPID(r); + this.localPidSystem.registerPid(r); } } diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystemTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystemTest.java index d2c24ba1..d6ae1eff 100644 --- a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystemTest.java +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/InMemoryIdentifierSystemTest.java @@ -5,73 +5,36 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; -import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import edu.kit.datamanager.pit.common.InvalidConfigException; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; class InMemoryIdentifierSystemTest { private InMemoryIdentifierSystem sys; - private TypeDefinition profile; - private TypeDefinition t1; - private TypeDefinition t2; - private TypeDefinition t3; @BeforeEach void setup() { this.sys = new InMemoryIdentifierSystem(); - this.t1 = new TypeDefinition(); - this.t1.setIdentifier("attribute1"); - this.t2 = new TypeDefinition(); - this.t2.setIdentifier("attribute2"); - this.t3 = new TypeDefinition(); - this.t3.setIdentifier("attribute3"); - - this.profile = new TypeDefinition(); - this.profile.setSubTypes(Map.of( - this.t1.getIdentifier(), this.t1, - this.t2.getIdentifier(), this.t2, - this.t3.getIdentifier(), this.t3 - )); - } - - @Test - void testQueryByType() throws IOException { - - PIDRecord p = new PIDRecord().withPID("test/pid"); - - // an empty registered record will return nothing - sys.registerPID(p); - PIDRecord queried = sys.queryByType(p.getPid(), profile); - assertTrue(queried.getPropertyIdentifiers().isEmpty()); - - // a record with matching types will return only those - p.addEntry(t1.getIdentifier(), "noName", "value"); - p.addEntry("something else", "noName", "noValue"); - sys.updatePID(p); - queried = sys.queryByType(p.getPid(), profile); - assertEquals(1, queried.getPropertyIdentifiers().size()); } @Test void testDeletePid() throws IOException { PIDRecord p = new PIDRecord().withPID("test/pid"); - sys.registerPID(p); + sys.registerPid(p); String pid = p.getPid(); assertThrows( UnsupportedOperationException.class, - () -> sys.deletePID(pid) + () -> sys.deletePid(pid) ); // actually, this is the case for any PID: assertThrows( UnsupportedOperationException.class, - () -> sys.deletePID("any PID") + () -> sys.deletePid("any PID") ); } @@ -80,11 +43,11 @@ void testResolveAll() throws InvalidConfigException, IOException { assertEquals(0, sys.resolveAllPidsOfPrefix().size()); PIDRecord p1 = new PIDRecord().withPID("p1"); - sys.registerPID(p1); + sys.registerPid(p1); assertEquals(1, sys.resolveAllPidsOfPrefix().size()); PIDRecord p2 = new PIDRecord().withPID("p2"); - sys.registerPID(p2); + sys.registerPid(p2); assertEquals(2, sys.resolveAllPidsOfPrefix().size()); } diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystemTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystemTest.java index 91d92f4b..8407038d 100644 --- a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystemTest.java +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystemTest.java @@ -9,7 +9,6 @@ import java.lang.reflect.Method; import java.util.Arrays; import java.util.HashSet; -import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; @@ -23,7 +22,6 @@ import edu.kit.datamanager.pit.common.InvalidConfigException; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.TypeDefinition; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystemQueryTest; /** @@ -47,11 +45,6 @@ class LocalPidSystemTest { @Autowired DataSourceProperties dataSourceProperties; - - private TypeDefinition profile; - private TypeDefinition t1; - private TypeDefinition t2; - private TypeDefinition t3; @BeforeEach void setup() throws InterruptedException, IOException { @@ -62,20 +55,6 @@ void setup() throws InterruptedException, IOException { assertNotNull(localPidSystem.getDatabase()); // ensure DB is empty localPidSystem.getDatabase().deleteAll(); - // prepare types and profiles - this.t1 = new TypeDefinition(); - this.t1.setIdentifier("attribute1"); - this.t2 = new TypeDefinition(); - this.t2.setIdentifier("attribute2"); - this.t3 = new TypeDefinition(); - this.t3.setIdentifier("attribute3"); - - this.profile = new TypeDefinition(); - this.profile.setSubTypes(Map.of( - this.t1.getIdentifier(), this.t1, - this.t2.getIdentifier(), this.t2, - this.t3.getIdentifier(), this.t3 - )); } @Test @@ -91,9 +70,9 @@ void testAllSystemTests() throws Exception { + "#objects/21.T11148/076759916209e5d62bd5\" weight=\"0\" view=\"ui\"" ); //rec.addEntry("10320/loc", "", "value"); - String pid = localPidSystem.registerPID(rec); + String pid = localPidSystem.registerPid(rec); assertEquals(rec.getPid(), pid); - PIDRecord newRec = localPidSystem.queryAllProperties(pid); + PIDRecord newRec = localPidSystem.queryPid(pid); assertEquals(rec, newRec); Set publicMethods = new HashSet<>(Arrays.asList(IIdentifierSystemQueryTest.class.getMethods())); @@ -106,8 +85,8 @@ void testAllSystemTests() throws Exception { try { test.invoke(systemTests, localPidSystem, rec.getPid()); } catch (Exception e) { - System.err.println(String.format("Test: %s", test)); - System.err.println(String.format("Exception: %s", e)); + System.err.printf("Test: %s%n", test); + System.err.printf("Exception: %s%n", e); throw e; } } else if (numParams == 3) { @@ -120,38 +99,20 @@ void testAllSystemTests() throws Exception { } } - @Test - void testQueryByType() throws IOException { - - PIDRecord p = new PIDRecord().withPID("test/pid"); - - // an empty registered record will return nothing - this.localPidSystem.registerPID(p); - PIDRecord queried = this.localPidSystem.queryByType(p.getPid(), profile); - assertTrue(queried.getPropertyIdentifiers().isEmpty()); - - // a record with matching types will return only those - p.addEntry(t1.getIdentifier(), "noName", "value"); - p.addEntry("something else", "noName", "noValue"); - this.localPidSystem.updatePID(p); - queried = this.localPidSystem.queryByType(p.getPid(), profile); - assertEquals(1, queried.getPropertyIdentifiers().size()); - } - @Test void testDeletePid() throws IOException { PIDRecord p = new PIDRecord().withPID("test/pid"); - this.localPidSystem.registerPID(p); + this.localPidSystem.registerPid(p); String pid = p.getPid(); assertThrows( UnsupportedOperationException.class, - () -> this.localPidSystem.deletePID(pid) + () -> this.localPidSystem.deletePid(pid) ); // actually, this is the case for any PID: assertThrows( UnsupportedOperationException.class, - () -> this.localPidSystem.deletePID("any PID") + () -> this.localPidSystem.deletePid("any PID") ); } @@ -160,11 +121,11 @@ void testResolveAll() throws InvalidConfigException, IOException { assertEquals(0, this.localPidSystem.resolveAllPidsOfPrefix().size()); PIDRecord p1 = new PIDRecord().withPID("p1"); - this.localPidSystem.registerPID(p1); + this.localPidSystem.registerPid(p1); assertEquals(1, this.localPidSystem.resolveAllPidsOfPrefix().size()); PIDRecord p2 = new PIDRecord().withPID("p2"); - this.localPidSystem.registerPID(p2); + this.localPidSystem.registerPid(p2); assertEquals(2, this.localPidSystem.resolveAllPidsOfPrefix().size()); } } diff --git a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java index b8662f4f..19b496e6 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import jakarta.servlet.ServletContext; import com.fasterxml.jackson.databind.ObjectMapper; @@ -81,6 +82,9 @@ class ExplicitValidationParametersTest { @Autowired ITypingService typingService; + @Autowired + ITypeRegistry typeRegistry; + @Autowired private ApplicationProperties appProps; @@ -96,7 +100,8 @@ class ExplicitValidationParametersTest { void setup() throws Exception { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext).build(); this.knownPidsDao.deleteAll(); - this.typingService.setValidationStrategy(this.appProps.defaultValidationStrategy()); + this.typingService.setValidationStrategy( + this.appProps.defaultValidationStrategy(typeRegistry)); } @Test @@ -252,12 +257,13 @@ void testResolvingValidRecordWithValidationFail() throws Exception { assertEquals(1, knownPidsDao.count()); String validPid = knownPidsDao.findAll().iterator().next().getPid(); - PIDRecord r = inMemory.queryAllProperties(validPid); + PIDRecord r = inMemory.queryPid(validPid); r.addEntry("21.T11148/076759916209e5d62bd5", "", "21.T11148/b9b76f887845e32d29f7"); r.addEntry("something wrong", "", "someVeryUniqueValue"); - inMemory.updatePID(r); + inMemory.updatePid(r); // ... so we need to re-enable validation here: - this.typingService.setValidationStrategy(this.appProps.defaultValidationStrategy()); + this.typingService.setValidationStrategy( + this.appProps.defaultValidationStrategy(typeRegistry)); MvcResult result = this.mockMvc .perform( diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java index 5e0f86ed..e56e63f3 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import jakarta.servlet.ServletContext; import com.fasterxml.jackson.databind.JsonNode; @@ -81,6 +82,9 @@ class RestWithInMemoryTest { @Autowired ITypingService typingService; + @Autowired + ITypeRegistry typeRegistry; + @Autowired private ApplicationProperties appProps; @@ -100,7 +104,8 @@ void setup() throws Exception { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext).build(); this.mapper = this.webApplicationContext.getBean("OBJECT_MAPPER_BEAN", ObjectMapper.class); this.knownPidsDao.deleteAll(); - this.typingService.setValidationStrategy(this.appProps.defaultValidationStrategy()); + this.typingService.setValidationStrategy( + this.appProps.defaultValidationStrategy(typeRegistry)); } @Test diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithLocalPidSystemTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithLocalPidSystemTest.java index aed83a39..b1656f9d 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithLocalPidSystemTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithLocalPidSystemTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import jakarta.servlet.ServletContext; import com.fasterxml.jackson.databind.ObjectMapper; @@ -83,6 +84,9 @@ class RestWithLocalPidSystemTest { @Autowired ITypingService typingService; + @Autowired + ITypeRegistry typeRegistry; + @Autowired ApplicationProperties appProps; @@ -102,7 +106,8 @@ void setup() throws Exception { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext).build(); this.mapper = this.webApplicationContext.getBean("OBJECT_MAPPER_BEAN", ObjectMapper.class); this.knownPidsDao.deleteAll(); - this.typingService.setValidationStrategy(this.appProps.defaultValidationStrategy()); + this.typingService.setValidationStrategy( + this.appProps.defaultValidationStrategy(typeRegistry)); } @Test From 91538cb84c8053c288eda3058e0b4f8633f6e2be Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 20 Nov 2024 19:05:40 +0100 Subject: [PATCH 009/108] fix: assumed wrong type of baseUrl --- .../edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java index bdd2c4f5..23e33219 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java @@ -23,7 +23,6 @@ import org.springframework.web.client.RestClient; import java.io.InputStream; -import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.time.Duration; @@ -121,7 +120,7 @@ protected Schema querySchema(String maybeSchemaPid) throws TypeNotFoundException JSONObject rawSchema = new JSONObject(new JSONTokener(inputStream)); schema = SchemaLoader.load(rawSchema); } catch (JSONException e) { - throw new ExternalServiceException(baseUrl, "Response (" + maybeSchemaPid + ") is not a valid schema."); + throw new ExternalServiceException(baseUrl.toString(), "Response (" + maybeSchemaPid + ") is not a valid schema."); } finally { inputStream.close(); } @@ -176,7 +175,7 @@ protected RegisteredProfile extractProfileInformation(String profilePid, JsonNod attributeRepeatable); if (obligationNode.isNull() || repeatableNode.isNull() || attributePid.trim().isEmpty()) { - throw new ExternalServiceException(baseUrl, "Malformed attribute in profile (%s): " + attribute); + throw new ExternalServiceException(baseUrl.toString(), "Malformed attribute in profile (%s): " + attribute); } attributes.add(attribute); @@ -197,6 +196,6 @@ public CompletableFuture queryAsProfile(String profilePid) { @Override public String getRegistryIdentifier() { - return baseUrl; + return baseUrl.toString(); } } From df87d27a3db55970e262c8134df7154eebd1fa39 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 20 Nov 2024 19:07:00 +0100 Subject: [PATCH 010/108] chore: cleanup, formatting, typos --- config/application-default.properties | 1 + config/application-docker.properties | 1 + .../pidsystem/impl/HandleProtocolAdapter.java | 4 ++-- .../pit/typeregistry/AttributeInfo.java | 2 -- .../RegisteredProfileAttribute.java | 1 - .../pit/typeregistry/impl/TypeApiTest.java | 8 +------ .../resources/test/application-doc.properties | 23 ------------------- 7 files changed, 5 insertions(+), 35 deletions(-) delete mode 100644 src/test/resources/test/application-doc.properties diff --git a/config/application-default.properties b/config/application-default.properties index 5dab2e6d..c80106df 100644 --- a/config/application-default.properties +++ b/config/application-default.properties @@ -160,6 +160,7 @@ pit.pidsystem.implementation = LOCAL #pit.pidsystem.handle-protocol.credentials.handleIdentifierPrefix = 21.T11981 #pit.pidsystem.handle-protocol.credentials.userHandle = 21.T11981/USER01 #pit.pidsystem.handle-protocol.credentials.privateKeyPath = test_prefix_data/21.T11981_USER01_300_privkey.bin + # The handle system supports the redirection of web browsers to a URL. # If your records may have such a URL stored in an attribute, you can # list the attributes here. The first attribute to be found will have diff --git a/config/application-docker.properties b/config/application-docker.properties index 90535d75..ccd357a5 100644 --- a/config/application-docker.properties +++ b/config/application-docker.properties @@ -160,6 +160,7 @@ pit.pidsystem.implementation = LOCAL #pit.pidsystem.handle-protocol.credentials.handleIdentifierPrefix = 21.T11981 #pit.pidsystem.handle-protocol.credentials.userHandle = 21.T11981/USER01 #pit.pidsystem.handle-protocol.credentials.privateKeyPath = test_prefix_data/21.T11981_USER01_300_privkey.bin + # The handle system supports the redirection of web browsers to a URL. # If your records may have such a URL stored in an attribute, you can # list the attributes here. The first attribute to be found will have diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java index ea8b633e..daadc473 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java @@ -84,7 +84,7 @@ public class HandleProtocolAdapter implements IIdentifierSystem { // Properties specific to this adapter. @Autowired - private HandleProtocolProperties props; + final private HandleProtocolProperties props; // Handle Protocol implementation private HSAdapter client; // indicates if the adapter can modify and create PIDs or just resolve them. @@ -99,7 +99,7 @@ public HandleProtocolAdapter(HandleProtocolProperties props) { /** * Initializes internal classes. - * We use this methos with the @PostConstruct annotation to run it + * We use this method with the @PostConstruct annotation to run it * after the constructor and after springs autowiring is done properly * to make sure that all properties are already autowired. * diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java index 7940bdc2..09239b7d 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java @@ -2,8 +2,6 @@ import org.everit.json.schema.Schema; -import java.util.List; - /** * @param pid the pid of this attribute * @param name a human-readable name, defined in the DTR diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfileAttribute.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfileAttribute.java index 5a85d940..0469d296 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfileAttribute.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfileAttribute.java @@ -1,6 +1,5 @@ package edu.kit.datamanager.pit.typeregistry; -import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.domain.PIDRecord; public record RegisteredProfileAttribute( diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java index 42dd11aa..a0f91a70 100644 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java @@ -1,16 +1,10 @@ package edu.kit.datamanager.pit.typeregistry.impl; import org.junit.jupiter.api.Test; -import org.springframework.web.client.RestClient; - -import java.net.URI; -import java.net.URISyntaxException; - -import static org.junit.jupiter.api.Assertions.*; class TypeApiTest { @Test - void dummy() throws URISyntaxException { + void dummy() { } } \ No newline at end of file diff --git a/src/test/resources/test/application-doc.properties b/src/test/resources/test/application-doc.properties deleted file mode 100644 index 046fe8a6..00000000 --- a/src/test/resources/test/application-doc.properties +++ /dev/null @@ -1,23 +0,0 @@ -repo.auth.jwtSecret: test123 - -repo.messaging.binding.exchange: notifications -repo.messaging.binding.queue: notificationQueue -repo.messaging.binding.routingKeys: notification.# - -repo.schedule.rate:1000 - -spring.datasource.driver-class-name: org.h2.Driver -spring.datasource.url: jdbc:h2:mem:db_doc;DB_CLOSE_DELAY=-1 -spring.datasource.username: sa -spring.datasource.password: sa - -spring.main.allow-bean-definition-overriding:true - -logging.level.edu.kit: TRACE - -spring.mail.host=smtp.gmail.com -spring.mail.port=587 -spring.mail.username=kitdm.service@gmail.com -spring.mail.password=kjbabdrjpehrofck -spring.mail.properties.mail.smtp.auth=true -spring.mail.properties.mail.smtp.starttls.enable=true \ No newline at end of file From a04dc32b701a3203ac201f4b19999fbfd8a829aa Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Thu, 12 Dec 2024 14:56:18 +0100 Subject: [PATCH 011/108] feat: introduce SchemaSetGenerator to support different schema generators per attribute. --- .../edu/kit/datamanager/pit/Application.java | 14 ++- .../pit/common/InvalidConfigException.java | 4 + .../impl/EmbeddedStrictValidatorStrategy.java | 37 ++++---- .../pit/typeregistry/AttributeInfo.java | 28 +++++- .../pit/typeregistry/impl/TypeApi.java | 59 +++++-------- .../schema/DtrTestSchemaGenerator.java | 86 +++++++++++++++++++ .../typeregistry/schema/SchemaGenerator.java | 12 +++ .../pit/typeregistry/schema/SchemaInfo.java | 21 +++++ .../schema/SchemaSetGenerator.java | 47 ++++++++++ .../schema/TypeApiSchemaGenerator.java | 73 ++++++++++++++++ .../pit/typeregistry/impl/TypeApiTest.java | 35 +++++++- 11 files changed, 350 insertions(+), 66 deletions(-) create mode 100644 src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java create mode 100644 src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaGenerator.java create mode 100644 src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java create mode 100644 src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java create mode 100644 src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java diff --git a/src/main/java/edu/kit/datamanager/pit/Application.java b/src/main/java/edu/kit/datamanager/pit/Application.java index 3b867aae..af17cd7d 100644 --- a/src/main/java/edu/kit/datamanager/pit/Application.java +++ b/src/main/java/edu/kit/datamanager/pit/Application.java @@ -32,6 +32,7 @@ import edu.kit.datamanager.pit.pitservice.impl.TypingService; import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import edu.kit.datamanager.pit.typeregistry.impl.TypeApi; +import edu.kit.datamanager.pit.typeregistry.schema.SchemaSetGenerator; import edu.kit.datamanager.pit.web.converter.SimplePidRecordConverter; import edu.kit.datamanager.security.filter.KeycloakJwtProperties; @@ -98,13 +99,18 @@ public Logger logger(InjectionPoint injectionPoint) { } @Bean - public ITypeRegistry typeRegistry(ApplicationProperties props) { - return new TypeApi(props); + public SchemaSetGenerator schemaSetGenerator(ApplicationProperties props) { + return new SchemaSetGenerator(props); } @Bean - public ITypingService typingService(IIdentifierSystem identifierSystem, ApplicationProperties props) { - return new TypingService(identifierSystem, typeRegistry(props)); + public ITypeRegistry typeRegistry(ApplicationProperties props, SchemaSetGenerator schemaSetGenerator) { + return new TypeApi(props, schemaSetGenerator); + } + + @Bean + public ITypingService typingService(IIdentifierSystem identifierSystem, ITypeRegistry typeRegistry) { + return new TypingService(identifierSystem, typeRegistry); } @Bean(name = "OBJECT_MAPPER_BEAN") diff --git a/src/main/java/edu/kit/datamanager/pit/common/InvalidConfigException.java b/src/main/java/edu/kit/datamanager/pit/common/InvalidConfigException.java index 3bb32c42..acf4b5b6 100644 --- a/src/main/java/edu/kit/datamanager/pit/common/InvalidConfigException.java +++ b/src/main/java/edu/kit/datamanager/pit/common/InvalidConfigException.java @@ -11,4 +11,8 @@ public class InvalidConfigException extends ResponseStatusException { public InvalidConfigException(String message) { super(HTTP_STATUS, message); } + + public InvalidConfigException(String message, Throwable error) { + super(HTTP_STATUS, message, error); + } } diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java index 0d5a3f6c..8564649d 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java @@ -13,7 +13,6 @@ import java.util.Set; import java.util.concurrent.*; -import org.everit.json.schema.ValidationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,14 +32,18 @@ public class EmbeddedStrictValidatorStrategy implements IValidationStrategy { protected boolean additionalAttributesAllowed; protected Set profileKeys; - public EmbeddedStrictValidatorStrategy(ITypeRegistry typeRegistry, ApplicationProperties config) { + public EmbeddedStrictValidatorStrategy( + ITypeRegistry typeRegistry, + ApplicationProperties config + ) { this.typeRegistry = typeRegistry; this.profileKeys = config.getProfileKeys(); } @Override - public void validate(PIDRecord pidRecord) throws RecordValidationException, ExternalServiceException { - + public void validate(PIDRecord pidRecord) + throws RecordValidationException, ExternalServiceException + { if (pidRecord.getPropertyIdentifiers().isEmpty()) { throw new RecordValidationException(pidRecord, "Record is empty!"); } @@ -51,19 +54,17 @@ public void validate(PIDRecord pidRecord) throws RecordValidationException, Exte .map(attributePid -> this.typeRegistry.queryAttributeInfo(attributePid)) // validate values using schema .map(attributeInfoFuture -> attributeInfoFuture.thenApply(attributeInfo -> { - for (String value : pidRecord.getPropertyValues(attributeInfo.pid())) { - try { - attributeInfo.jsonSchema().validate(value); - } catch (ValidationException e) { - throw new RecordValidationException( - pidRecord, - "Attribute %s has a non-complying value %s".formatted(attributeInfo.pid(), value), - e - ); - } - } - return attributeInfo; - })) + for (String value : pidRecord.getPropertyValues(attributeInfo.pid())) { + boolean isValid = attributeInfo.validate(value); + if (!isValid) { + throw new RecordValidationException( + pidRecord, + "Attribute %s has a non-complying value %s" + .formatted(attributeInfo.pid(), value)); + } + } + return attributeInfo; + })) // resolve profiles and apply their validation .map(attributeInfoFuture -> attributeInfoFuture.thenApply(attributeInfo -> { boolean isProfile = this.profileKeys.contains(attributeInfo.pid()); @@ -84,7 +85,7 @@ public void validate(PIDRecord pidRecord) throws RecordValidationException, Exte CompletableFuture.allOf(attributeInfoFutures.toArray(new CompletableFuture[0])).join(); } catch (CompletionException e) { throwRecordValidationExceptionCause(e); - throw new ExternalServiceException(this.typeRegistry.getRegistryIdentifier().toString()); + throw new ExternalServiceException(this.typeRegistry.getRegistryIdentifier()); } catch (CancellationException e) { throwRecordValidationExceptionCause(e); throw new RecordValidationException( diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java index 09239b7d..ef4207ae 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java @@ -1,10 +1,16 @@ package edu.kit.datamanager.pit.typeregistry; +import edu.kit.datamanager.pit.typeregistry.schema.SchemaInfo; import org.everit.json.schema.Schema; +import org.everit.json.schema.ValidationException; + +import java.util.Collection; +import java.util.Objects; /** * @param pid the pid of this attribute - * @param name a human-readable name, defined in the DTR + * @param name a human-readable name, defined in the DTR for this type. + * Note this is usually different from the name in a specific profile! * @param typeName name of the schema type of this attribute in the DTR, * e.g. "Profile", "InfoType", "Special-Info-Type", ... * @param jsonSchema the json schema to validate a value of this attribute @@ -13,5 +19,21 @@ public record AttributeInfo( String pid, String name, String typeName, - Schema jsonSchema -) {} + Collection jsonSchema +) { + public boolean validate(String value) { + return this.jsonSchema().stream() + .map(SchemaInfo::schema) + .filter(Objects::nonNull) + .anyMatch(schema -> validate(schema, value)); + } + + private boolean validate(Schema schema, String value) { + try { + schema.validate(value); + } catch (ValidationException e) { + return false; + } + return true; + } +} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java index 23e33219..7189448e 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java @@ -13,15 +13,13 @@ import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import edu.kit.datamanager.pit.typeregistry.RegisteredProfile; import edu.kit.datamanager.pit.typeregistry.RegisteredProfileAttribute; -import org.everit.json.schema.Schema; -import org.everit.json.schema.loader.SchemaLoader; -import org.json.JSONException; -import org.json.JSONObject; -import org.json.JSONTokener; +import edu.kit.datamanager.pit.typeregistry.schema.SchemaInfo; +import edu.kit.datamanager.pit.typeregistry.schema.SchemaSetGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.client.RestClient; +import java.io.IOException; import java.io.InputStream; import java.net.URISyntaxException; import java.net.URL; @@ -29,6 +27,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -41,9 +40,12 @@ public class TypeApi implements ITypeRegistry { protected final AsyncLoadingCache profileCache; protected final AsyncLoadingCache attributeCache; - public TypeApi(ApplicationProperties properties) { + protected final SchemaSetGenerator schemaSetGenerator; + + public TypeApi(ApplicationProperties properties, SchemaSetGenerator schemaSetGenerator) { + this.schemaSetGenerator = schemaSetGenerator; this.baseUrl = properties.getTypeRegistryUri(); - String baseUri = null; + String baseUri; try { baseUri = baseUrl.toURI().resolve("v1/types").toString(); } catch (URISyntaxException e) { @@ -82,53 +84,34 @@ public TypeApi(ApplicationProperties properties) { }); } - private AttributeInfo queryAttribute(String attributePid) { + protected AttributeInfo queryAttribute(String attributePid) { return http.get() .uri(uriBuilder -> uriBuilder .path(attributePid) .build()) .exchange((clientRequest, clientResponse) -> { if (clientResponse.getStatusCode().is2xxSuccessful()) { - InputStream inputStream = clientResponse.getBody(); - String body = new String(inputStream.readAllBytes()); - inputStream.close(); - return extractAttributeInformation(attributePid, Application.jsonObjectMapper().readTree(body)); + try (InputStream inputStream = clientResponse.getBody()) { + String body = new String(inputStream.readAllBytes()); + return extractAttributeInformation(attributePid, Application.jsonObjectMapper().readTree(body)); + } catch (IOException e) { + throw new TypeNotFoundException(attributePid); + } } else { throw new TypeNotFoundException(attributePid); } }); } - private AttributeInfo extractAttributeInformation(String attributePid, JsonNode jsonNode) { + protected AttributeInfo extractAttributeInformation(String attributePid, JsonNode jsonNode) { String typeName = jsonNode.path("type").asText(); String name = jsonNode.path("name").asText(); - Schema schema = this.querySchema(attributePid); - return new AttributeInfo(attributePid, name, typeName, schema); + Set schemas = this.querySchemas(attributePid); + return new AttributeInfo(attributePid, name, typeName, schemas); } - protected Schema querySchema(String maybeSchemaPid) throws TypeNotFoundException, ExternalServiceException { - return http.get() - .uri(uriBuilder -> uriBuilder - .pathSegment("schema") - .path(maybeSchemaPid) - .build()) - .exchange((clientRequest, clientResponse) -> { - if (clientResponse.getStatusCode().is2xxSuccessful()) { - InputStream inputStream = clientResponse.getBody(); - Schema schema; - try { - JSONObject rawSchema = new JSONObject(new JSONTokener(inputStream)); - schema = SchemaLoader.load(rawSchema); - } catch (JSONException e) { - throw new ExternalServiceException(baseUrl.toString(), "Response (" + maybeSchemaPid + ") is not a valid schema."); - } finally { - inputStream.close(); - } - return schema; - } else { - throw new TypeNotFoundException(maybeSchemaPid); - } - }); + protected Set querySchemas(String maybeSchemaPid) throws TypeNotFoundException, ExternalServiceException { + return schemaSetGenerator.generateFor(maybeSchemaPid).join(); } protected RegisteredProfile queryProfile(String maybeProfilePid) throws TypeNotFoundException, ExternalServiceException { diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java new file mode 100644 index 00000000..533623e0 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java @@ -0,0 +1,86 @@ +package edu.kit.datamanager.pit.typeregistry.schema; + +import edu.kit.datamanager.pit.common.ExternalServiceException; +import edu.kit.datamanager.pit.common.InvalidConfigException; +import edu.kit.datamanager.pit.common.TypeNotFoundException; +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import jakarta.validation.constraints.NotNull; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.client.LaxRedirectStrategy; +import org.everit.json.schema.Schema; +import org.everit.json.schema.loader.SchemaLoader; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; + +public class DtrTestSchemaGenerator implements SchemaGenerator { + + protected final URI baseUrl; + protected final HttpClient http; + + public DtrTestSchemaGenerator(@NotNull ApplicationProperties props) { + try { + this.baseUrl = props.getHandleBaseUri().toURI(); + } catch (URISyntaxException e) { + throw new InvalidConfigException("BaseUrl not configured properly."); + } + this.http = HttpClientBuilder.create() + .setRedirectStrategy(new LaxRedirectStrategy()) + .build(); + } + + @Override + public SchemaInfo generateSchema(@NotNull String maybeTypePid) { + HttpGet request = new HttpGet(baseUrl.resolve("/" + maybeTypePid)); + try { + return http.execute( + request, + response -> { + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode / 100 == 2) { + Schema schema = null; + try (InputStream inputStream = response.getEntity().getContent()) { + JSONObject jsonBody = new JSONObject(new JSONTokener(inputStream)); + JSONObject rawSchema = new JSONObject(new JSONTokener(jsonBody.getString("validationSchema"))); + schema = SchemaLoader.load(rawSchema); + } catch (JSONException e) { + return new SchemaInfo( + this.baseUrl.toString(), + schema, + new ExternalServiceException(baseUrl.toString(), "No valid schema found resolving PID " + maybeTypePid) + ); + } + return new SchemaInfo(this.baseUrl.toString(), schema, null); + } else if (statusCode == 404) { + return new SchemaInfo( + this.baseUrl.toString(), + null, + new TypeNotFoundException(maybeTypePid) + ); + } else { + return new SchemaInfo( + this.baseUrl.toString(), + null, + new ExternalServiceException( + this.baseUrl.toString(), + "Error generating schema: %s - %s".formatted(statusCode, response.getStatusLine().toString())) + ); + } + } + ); + } catch (IOException e) { + return new SchemaInfo( + this.baseUrl.toString(), + null, + new ExternalServiceException(baseUrl.toString(), "Error communicating with service.", e) + ); + } + } +} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaGenerator.java new file mode 100644 index 00000000..0e438f97 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaGenerator.java @@ -0,0 +1,12 @@ +package edu.kit.datamanager.pit.typeregistry.schema; + +import edu.kit.datamanager.pit.common.ExternalServiceException; + +public interface SchemaGenerator { + /** + * Generates a schema for the given type. + * @param maybeTypePid the PID for the type to generate a schema for. + * @return the generated schema. + */ + SchemaInfo generateSchema(String maybeTypePid) throws ExternalServiceException; +} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java new file mode 100644 index 00000000..d6143c40 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java @@ -0,0 +1,21 @@ +package edu.kit.datamanager.pit.typeregistry.schema; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; +import org.everit.json.schema.Schema; + +import java.util.Optional; + +public record SchemaInfo( + @NotNull String origin, + @Nullable Schema schema, + @Nullable Throwable error +) { + Optional hasError() { + return Optional.ofNullable(this.error); + } + + Optional hasSchema() { + return Optional.ofNullable(this.schema); + } +} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java new file mode 100644 index 00000000..3b8a7410 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java @@ -0,0 +1,47 @@ +package edu.kit.datamanager.pit.typeregistry.schema; + +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.github.benmanes.caffeine.cache.Caffeine; +import edu.kit.datamanager.pit.Application; +import edu.kit.datamanager.pit.configuration.ApplicationProperties; + +import java.time.Duration; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +public class SchemaSetGenerator { + private final Set GENERATORS; + private final AsyncLoadingCache> CACHE; + + public SchemaSetGenerator(ApplicationProperties props) { + GENERATORS = Set.of( + new TypeApiSchemaGenerator(props), + new DtrTestSchemaGenerator(props) + ); + + CACHE = Caffeine.newBuilder() + .maximumSize(props.getMaximumSize()) + .executor(Application.EXECUTOR) + .refreshAfterWrite(Duration.ofMinutes(props.getExpireAfterWrite() / 2)) + .expireAfterWrite(props.getExpireAfterWrite(), TimeUnit.MINUTES) + .buildAsync(attributePid -> GENERATORS.stream() + .map(schemaGenerator -> schemaGenerator.generateSchema(attributePid)) + .collect(Collectors.toSet()) + ); + } + + /** + * Will generate a set of possible schemas for a given attribute PID and provide information about origin and success. + *

+ * Note that generation may fail and the schema may be null. In such cases, there will usually be error information + * available. + * + * @param attributePid the PID of the attribute to generate schemas for. + * @return a set of information about the generated schemas, including the schemas themselves, if generation succeeded. + */ + public CompletableFuture> generateFor(final String attributePid) { + return this.CACHE.get(attributePid); + } +} diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java new file mode 100644 index 00000000..8fd58a65 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java @@ -0,0 +1,73 @@ +package edu.kit.datamanager.pit.typeregistry.schema; + +import edu.kit.datamanager.pit.common.ExternalServiceException; +import edu.kit.datamanager.pit.common.InvalidConfigException; +import edu.kit.datamanager.pit.common.TypeNotFoundException; +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import jakarta.validation.constraints.NotNull; +import org.everit.json.schema.Schema; +import org.everit.json.schema.loader.SchemaLoader; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.client.RestClient; + +import java.io.InputStream; +import java.net.URISyntaxException; +import java.net.URL; + +public class TypeApiSchemaGenerator implements SchemaGenerator { + + protected final URL baseUrl; + protected final RestClient http; + + public TypeApiSchemaGenerator(@NotNull ApplicationProperties props) { + this.baseUrl = props.getTypeRegistryUri(); + String baseUri; + try { + baseUri = baseUrl.toURI().resolve("v1/types").toString(); + } catch (URISyntaxException e) { + throw new InvalidConfigException("Type-Api base url not valid: " + baseUrl, e); + } + this.http = RestClient.builder().baseUrl(baseUri).build(); + } + + @Override + public SchemaInfo generateSchema(@NotNull String maybeTypePid) { + return http.get() + .uri(uriBuilder -> uriBuilder + .pathSegment("schema") + .path(maybeTypePid) + .build()) + .exchange((request, response) -> { + HttpStatusCode statusCode = response.getStatusCode(); + if (statusCode.is2xxSuccessful()) { + Schema schema = null; + try (InputStream inputStream = response.getBody()) { + JSONObject rawSchema = new JSONObject(new JSONTokener(inputStream)); + schema = SchemaLoader.load(rawSchema); + } catch (JSONException e) { + return new SchemaInfo( + this.baseUrl.toString(), + schema, + new ExternalServiceException(baseUrl.toString(), "Response (" + maybeTypePid + ") is not a valid schema.") + ); + } + return new SchemaInfo(this.baseUrl.toString(), schema, null); + } else if (statusCode.value() == 404) { + return new SchemaInfo( + this.baseUrl.toString(), + null, + new TypeNotFoundException(maybeTypePid)); + } else { + return new SchemaInfo( + this.baseUrl.toString(), + null, + new ExternalServiceException( + this.baseUrl.toString(), + "Error generating schema: %s - %s".formatted(statusCode.value(), response.getStatusText()))); + } + }); + } +} diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java index a0f91a70..c645cd7a 100644 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java @@ -1,10 +1,39 @@ -package edu.kit.datamanager.pit.typeregistry.impl; + package edu.kit.datamanager.pit.typeregistry.impl; +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import edu.kit.datamanager.pit.typeregistry.schema.SchemaInfo; +import edu.kit.datamanager.pit.typeregistry.schema.SchemaSetGenerator; import org.junit.jupiter.api.Test; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + class TypeApiTest { - @Test - void dummy() { + // PID of checksum type in dtr-test. Currently (Dec 2024), the schema that type-api generated is malformed. + public static final String PID_COMPLEX_TYPE_CHECKSUM_DTRTEST = "21.T11148/82e2503c49209e987740"; + private final TypeApi dtr; + + TypeApiTest() throws MalformedURLException, URISyntaxException { + ApplicationProperties props = new ApplicationProperties(); + // set cache properties + props.setExpireAfterWrite(10); + props.setMaximumSize(1000); + // set type registry + props.setTypeRegistryUri(new URI("https://typeapi.lab.pidconsortium.net").toURL()); + props.setHandleBaseUri(new URI("https://hdl.handle.net").toURL()); + this.dtr = new TypeApi(props, new SchemaSetGenerator(props)); + } + + @Test + void querySchemaOfComplexType() { + Set s = dtr.querySchemas(PID_COMPLEX_TYPE_CHECKSUM_DTRTEST); + assertEquals(2, s.size()); + // TODO this test is just for proof of concept testing. It does not mean the two schemas were retrieved successfully. + // This is due to error information also being collected within a SchemaInfo type for later use. } } \ No newline at end of file From 2221af1754571202ea95ca00ed6ee38b3f69f6d7 Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Fri, 13 Dec 2024 15:26:20 +0100 Subject: [PATCH 012/108] added bulk create feature --- .../pit/common/RecordValidationException.java | 53 ++- .../pit/web/ITypingRestResource.java | 359 ++++++++++-------- .../pit/web/impl/TypingRESTResourceImpl.java | 181 ++++++--- 3 files changed, 370 insertions(+), 223 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java b/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java index 520cc748..79c109b2 100644 --- a/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java +++ b/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java @@ -1,9 +1,24 @@ -package edu.kit.datamanager.pit.common; +/* + * Copyright (c) 2024 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ -import org.springframework.web.server.ResponseStatusException; -import org.springframework.http.HttpStatus; +package edu.kit.datamanager.pit.common; import edu.kit.datamanager.pit.domain.PIDRecord; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; /** * Indicates that a PID was given which could not be resolved to answer the @@ -11,24 +26,24 @@ */ public class RecordValidationException extends ResponseStatusException { - private static final String VALIDATION_OF_RECORD = "Validation of record "; - private static final long serialVersionUID = 1L; - private static final HttpStatus HTTP_STATUS = HttpStatus.BAD_REQUEST; + private static final String VALIDATION_OF_RECORD = "Validation of record "; + private static final long serialVersionUID = 1L; + private static final HttpStatus HTTP_STATUS = HttpStatus.BAD_REQUEST; - // For cases in which the PID record shold be appended to the error response. - private final transient PIDRecord pidRecord; + // For cases in which the PID record should be appended to the error response. + private final transient PIDRecord pidRecord; - public RecordValidationException(PIDRecord pidRecord) { - super(HTTP_STATUS, VALIDATION_OF_RECORD + pidRecord.getPid() + " failed."); - this.pidRecord = pidRecord; - } + public RecordValidationException(PIDRecord pidRecord) { + super(HTTP_STATUS, VALIDATION_OF_RECORD + pidRecord.getPid() + " failed."); + this.pidRecord = pidRecord; + } - public RecordValidationException(PIDRecord pidRecord, String reason) { - super(HTTP_STATUS, VALIDATION_OF_RECORD + pidRecord.getPid() + " failed. Reason:\n" + reason); - this.pidRecord = pidRecord; - } + public RecordValidationException(PIDRecord pidRecord, String reason) { + super(HTTP_STATUS, VALIDATION_OF_RECORD + pidRecord.getPid() + " failed. Reason:\n" + reason); + this.pidRecord = pidRecord; + } - public PIDRecord getPidRecord() { - return pidRecord; - } + public PIDRecord getPidRecord() { + return pidRecord; + } } diff --git a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java index 73d1dd13..3f2883a0 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java +++ b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java @@ -1,5 +1,5 @@ /* - * Copyright 2020 Karlsruhe Institute of Technology. + * Copyright (c) 2024 Karlsruhe Institute of Technology. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,9 +15,9 @@ */ package edu.kit.datamanager.pit.web; -import edu.kit.datamanager.pit.pidlog.KnownPid; import edu.kit.datamanager.pit.domain.PIDRecord; import edu.kit.datamanager.pit.domain.SimplePidRecord; +import edu.kit.datamanager.pit.pidlog.KnownPid; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; @@ -25,39 +25,87 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; - -import java.io.IOException; -import java.time.Instant; -import java.util.List; - import jakarta.servlet.http.HttpServletResponse; - import org.springdoc.core.converters.models.PageableAsQueryParam; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.*; import org.springframework.web.context.request.WebRequest; import org.springframework.web.util.UriComponentsBuilder; -import org.springframework.data.domain.Sort; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; /** - * * @author jejkal */ public interface ITypingRestResource { + /** + * Create multiple, possibly related PID records using the record information. + * This endpoint is a convenience method to create multiple PID records at once. + * For connecting records, the PID fields must be specified and the value may be used in the value fields of other PIDRecordEntries. + * + * @param rec A list of PID records. + * @param dryrun If true, only validation will be done and no PIDs will be created. No data will be changed and no services will be notified. + * @return either 201 and a list of record representations, or an error (see ApiResponse annotations and tests). + * @throws IOException if an error occurs. + * @throws edu.kit.datamanager.pit.common.RecordValidationException if any of the records is invalid or a PID was used for multiple records in the same request. + */ + @PostMapping( + path = "pids/", + consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, + produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} + ) + @Operation( + summary = "Create a multiple, possibly related PID records", + description = "Create multiple, possibly related PID records using the record information from the request body. To connect records the PID fields must be specified and the value may be used in the value fields of other PID Record entries. The provided PIDs will be overwritten as defined by the generator strategy." + ) + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "The body containing a list of all PID record values as they should be in the new PID records.", + required = true, + content = { + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, array = @ArraySchema(schema = @Schema(implementation = PIDRecord.class))), + @Content(mediaType = SimplePidRecord.CONTENT_TYPE, array = @ArraySchema(schema = @Schema(implementation = SimplePidRecord.class))) + } + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "Created", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, array = @ArraySchema(schema = @Schema(implementation = PIDRecord.class))), + @Content(mediaType = SimplePidRecord.CONTENT_TYPE, array = @ArraySchema(schema = @Schema(implementation = SimplePidRecord.class))) + }), + @ApiResponse(responseCode = "400", description = "Validation failed. See body for details. Contains also the validated records.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "406", description = "Provided input is invalid with regard to the supported accept header (Not acceptable)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "415", description = "Provided input is invalid with regard to the supported content types. (Unsupported Mediatype)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "409", description = "If providing own PIDs is enabled 409 indicates, that the PID already exists.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + }) + ResponseEntity> createPIDs( + @RequestBody final List rec, + + @Parameter(description = "If true, only validation will be done and no PIDs will be created. No data will be changed and no services will be notified.", required = false) + @RequestParam(name = "dryrun", required = false, defaultValue = "false") + boolean dryrun, + + final WebRequest request, + final HttpServletResponse response, + final UriComponentsBuilder uriBuilder + ) throws IOException; + /** * Create a new PID using the record information provided in the request body. * The record is expected to contain the identifier of the matching profile. * Before creating the record, the record information will be validated against * the profile. - * + *

* Important note: Validation caches recently used type information locally. * Therefore, changes in a registry may take a few minutes to be reflected * within the Typed PID Maker. This speeds up validation drastically in most @@ -66,48 +114,45 @@ public interface ITypingRestResource { * be aware that in general, validation may take up some time. * * @param rec The PID record. - * * @return either 201 and a record representation, or an error (see ApiResponse - * annotations and tests). - * + * annotations and tests). * @throws IOException */ @PostMapping( - path = "pid/", - consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, - produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} + path = "pid/", + consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, + produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} ) @Operation( - summary = "Create a new PID record", - description = "Create a new PID record using the record information from the request body." + summary = "Create a new PID record", + description = "Create a new PID record using the record information from the request body." ) @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "The body containing all PID record values as they should be in the new PIDs record.", - required = true, - content = { - @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), - @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) - } + description = "The body containing all PID record values as they should be in the new PIDs record.", + required = true, + content = { + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), + @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) + } ) @ApiResponses(value = { - @ApiResponse( - responseCode = "201", - description = "Created", - content = { - @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), - @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) - }), - @ApiResponse(responseCode = "400", description = "Validation failed. See body for details. Contains also the validated record.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "406", description = "Provided input is invalid with regard to the supported accept header (Not acceptable)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "415", description = "Provided input is invalid with regard to the supported content types. (Unsupported Mediatype)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "409", description = "If providing an own PID is enabled 409 indicates, that the PID already exists.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @ApiResponse( + responseCode = "201", + description = "Created", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), + @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) + }), + @ApiResponse(responseCode = "400", description = "Validation failed. See body for details. Contains also the validated record.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "406", description = "Provided input is invalid with regard to the supported accept header (Not acceptable)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "415", description = "Provided input is invalid with regard to the supported content types. (Unsupported Mediatype)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "409", description = "If providing an own PID is enabled 409 indicates, that the PID already exists.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) }) - public ResponseEntity createPID( - @RequestBody - final PIDRecord rec, - + ResponseEntity createPID( + @RequestBody final PIDRecord rec, + @Parameter(description = "If true, only validation will be done and no PID will be created. No data will be changed and no services will be notified.", required = false) @RequestParam(name = "dryrun", required = false, defaultValue = "false") boolean dryrun, @@ -121,52 +166,49 @@ public ResponseEntity createPID( * Update the given PIDs record using the information provided in the request * body. The record is expected to contain the identifier of the matching * profile. Conditions for a valid record are the same as for creation. - * + *

* Important note: Validation may take up to 30+ seconds. For details, see the * documentation of "POST /pid/". * * @param rec the PID record. - * * @return the record (on success). - * * @throws IOException */ @PutMapping( - path = "pid/**", - consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, - produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} + path = "pid/**", + consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, + produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} ) @Operation( - summary = "Update an existing PID record", - description = "Update an existing PID record using the record information from the request body." + summary = "Update an existing PID record", + description = "Update an existing PID record using the record information from the request body." ) @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "The body containing all PID record values as they should be after the update.", - required = true, - content = { - @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), - @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) - } + description = "The body containing all PID record values as they should be after the update.", + required = true, + content = { + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), + @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) + } ) @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Success.", - content = { - @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), - @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) - }), - @ApiResponse(responseCode = "400", description = "Validation failed. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "406", description = "Provided input is invalid with regard to the supported accept header (Not acceptable)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "415", description = "Provided input is invalid with regard to the supported content types. (Unsupported Mediatype)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "412", description = "ETag comparison failed (Precondition failed)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "428", description = "No ETag given in If-Match header (Precondition required)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @ApiResponse( + responseCode = "200", + description = "Success.", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), + @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) + }), + @ApiResponse(responseCode = "400", description = "Validation failed. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "406", description = "Provided input is invalid with regard to the supported accept header (Not acceptable)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "415", description = "Provided input is invalid with regard to the supported content types. (Unsupported Mediatype)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "412", description = "ETag comparison failed (Precondition failed)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "428", description = "No ETag given in If-Match header (Precondition required)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) }) - public ResponseEntity updatePID( - @RequestBody - final PIDRecord rec, + ResponseEntity updatePID( + @RequestBody final PIDRecord rec, final WebRequest request, final HttpServletResponse response, @@ -177,29 +219,28 @@ public ResponseEntity updatePID( * Get the record of the given PID (or test if it exists). * * @return the record. - * * @throws IOException */ @GetMapping( - path = "pid/**", - produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} + path = "pid/**", + produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} ) @Operation(summary = "Get the record of the given PID.", description = "Get the record to the given PID, if it exists. No validation is performed by default.") @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Found", - content = { - @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), - @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) - } - ), - @ApiResponse(responseCode = "400", description = "Validation failed. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "404", description = "Not found", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), - @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @ApiResponse( + responseCode = "200", + description = "Found", + content = { + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PIDRecord.class)), + @Content(mediaType = SimplePidRecord.CONTENT_TYPE, schema = @Schema(implementation = SimplePidRecord.class)) + } + ), + @ApiResponse(responseCode = "400", description = "Validation failed. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "404", description = "Not found", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) }) - public ResponseEntity getRecord ( + ResponseEntity getRecord( @Parameter(description = "If true, validation will be run on the resolved PID. On failure, an error will be returned. On success, the PID will be resolved.", required = false) @RequestParam(name = "validation", required = false, defaultValue = "false") boolean validation, @@ -213,37 +254,37 @@ public ResponseEntity getRecord ( * Requests a PID from the local store. If this PID is known, it will be * returned together with the timestamps of creation and modification executed * on this PID by this service. - * + *

* This store is not a cache! Instead, the service remembers every PID which it * created (and resolved, depending on the configuration parameter * `pit.storage.strategy` of the service) on request. - * + * * @return the known PID and its timestamps. * @throws IOException */ @Operation( - summary = "Returns a PID and its timestamps from the local store, if available.", - description = "Returns a PID from the local store. This store is not a cache! Instead, the" + summary = "Returns a PID and its timestamps from the local store, if available.", + description = "Returns a PID from the local store. This store is not a cache! Instead, the" + " service remembers every PID which it created (and resolved, depending on the" + " configuration parameter `pit.storage.strategy` of the service) on request. If" + " this PID is known, it will be returned together with the timestamps of" + " creation and modification executed on this PID by this service.", - responses = { - @ApiResponse( - responseCode = "200", - description = "If the PID is known and its information was returned.", - content = @Content(schema = @Schema(implementation = KnownPid.class)) - ), - @ApiResponse( - responseCode = "404", - description = "If the PID is unknown.", - content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) - ), - @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) - } + responses = { + @ApiResponse( + responseCode = "200", + description = "If the PID is known and its information was returned.", + content = @Content(schema = @Schema(implementation = KnownPid.class)) + ), + @ApiResponse( + responseCode = "404", + description = "If the PID is unknown.", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE) + ), + @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + } ) @GetMapping(path = "known-pid/**") - public ResponseEntity findByPid( + ResponseEntity findByPid( final WebRequest request, final HttpServletResponse response, final UriComponentsBuilder uriBuilder @@ -252,12 +293,12 @@ public ResponseEntity findByPid( /** * Returns all known PIDs, limited by the given page size and number. * Several filtering criteria are also available. - * + *

* Known PIDs are defined as being stored in a local store. This store is not a * cache! Instead, the service remembers every PID which it created (and * resolved, depending on the configuration parameter `pit.storage.strategy` of * the service) on request. - * + * * @param createdAfter defines the earliest date for the creation timestamp. * @param createdBefore defines the latest date for the creation timestamp. * @param modifiedAfter defines the earliest date for the modification @@ -268,25 +309,25 @@ public ResponseEntity findByPid( * @return the PIDs matching all given contraints. */ @Operation( - summary = "Returns all known PIDs. Supports paging, filtering criteria, and different formats.", - description = "Returns all known PIDs, limited by the given page size and number. " - + "Several filtering criteria are also available. Known PIDs are defined as " - + "being stored in a local store. This store is not a cache! Instead, the " - + "service remembers every PID which it created (and resolved, depending on " - + "the configuration parameter `pit.storage.strategy` of the service) on " - + "request. Use the Accept header to adjust the format.", - responses = { - @ApiResponse( - responseCode = "200", - description = "If the request was valid. May return an empty list.", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = KnownPid.class))) - ), - @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) - } + summary = "Returns all known PIDs. Supports paging, filtering criteria, and different formats.", + description = "Returns all known PIDs, limited by the given page size and number. " + + "Several filtering criteria are also available. Known PIDs are defined as " + + "being stored in a local store. This store is not a cache! Instead, the " + + "service remembers every PID which it created (and resolved, depending on " + + "the configuration parameter `pit.storage.strategy` of the service) on " + + "request. Use the Accept header to adjust the format.", + responses = { + @ApiResponse( + responseCode = "200", + description = "If the request was valid. May return an empty list.", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = KnownPid.class))) + ), + @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + } ) @GetMapping(path = "known-pid") @PageableAsQueryParam - public ResponseEntity> findAll( + ResponseEntity> findAll( @Parameter(name = "created_after", description = "The UTC time of the earliest creation timestamp of a returned PID.", required = false) @RequestParam(name = "created_after", required = false) Instant createdAfter, @@ -298,7 +339,7 @@ public ResponseEntity> findAll( @Parameter(name = "modified_after", description = "The UTC time of the earliest modification timestamp of a returned PID.", required = false) @RequestParam(name = "modified_after", required = false) Instant modifiedAfter, - + @Parameter(name = "modified_before", description = "The UTC time of the latest modification timestamp of a returned PID.", required = false) @RequestParam(name = "modified_before", required = false) Instant modifiedBefore, @@ -306,18 +347,18 @@ public ResponseEntity> findAll( @Parameter(hidden = true) @PageableDefault(sort = {"modified"}, direction = Sort.Direction.ASC) Pageable pageable, - + WebRequest request, - + HttpServletResponse response, - + UriComponentsBuilder uriBuilder ) throws IOException; /** * Like findAll, but the return value is formatted for the tabulator * javascript library. - * + * * @param createdAfter defines the earliest date for the creation timestamp. * @param createdBefore defines the latest date for the creation timestamp. * @param modifiedAfter defines the earliest date for the modification @@ -327,26 +368,26 @@ public ResponseEntity> findAll( * lists. * @return the PIDs matching all given contraints. */ - @Operation( - summary = "Returns all known PIDs. Supports paging, filtering criteria, and different formats.", - description = "Returns all known PIDs, limited by the given page size and number. " - + "Several filtering criteria are also available. Known PIDs are defined as " - + "being stored in a local store. This store is not a cache! Instead, the " - + "service remembers every PID which it created (and resolved, depending on " - + "the configuration parameter `pit.storage.strategy` of the service) on " - + "request. Use the Accept header to adjust the format.", - responses = { - @ApiResponse( - responseCode = "200", - description = "If the request was valid. May return an empty list.", - content = @Content(schema = @Schema(implementation = TabulatorPaginationFormat.class)) - ), - @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) - } + @Operation( + summary = "Returns all known PIDs. Supports paging, filtering criteria, and different formats.", + description = "Returns all known PIDs, limited by the given page size and number. " + + "Several filtering criteria are also available. Known PIDs are defined as " + + "being stored in a local store. This store is not a cache! Instead, the " + + "service remembers every PID which it created (and resolved, depending on " + + "the configuration parameter `pit.storage.strategy` of the service) on " + + "request. Use the Accept header to adjust the format.", + responses = { + @ApiResponse( + responseCode = "200", + description = "If the request was valid. May return an empty list.", + content = @Content(schema = @Schema(implementation = TabulatorPaginationFormat.class)) + ), + @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + } ) - @GetMapping(path = "known-pid", produces={"application/tabulator+json"}, headers = "Accept=application/tabulator+json") + @GetMapping(path = "known-pid", produces = {"application/tabulator+json"}, headers = "Accept=application/tabulator+json") @PageableAsQueryParam - public ResponseEntity> findAllForTabular( + ResponseEntity> findAllForTabular( @Parameter(name = "created_after", description = "The UTC time of the earliest creation timestamp of a returned PID.", required = false) @RequestParam(name = "created_after", required = false) Instant createdAfter, @@ -358,7 +399,7 @@ public ResponseEntity> findAllForTabular( @Parameter(name = "modified_after", description = "The UTC time of the earliest modification timestamp of a returned PID.", required = false) @RequestParam(name = "modified_after", required = false) Instant modifiedAfter, - + @Parameter(name = "modified_before", description = "The UTC time of the latest modification timestamp of a returned PID.", required = false) @RequestParam(name = "modified_before", required = false) Instant modifiedBefore, @@ -366,11 +407,11 @@ public ResponseEntity> findAllForTabular( @Parameter(hidden = true) @PageableDefault(sort = {"modified"}, direction = Sort.Direction.ASC) Pageable pageable, - + WebRequest request, - + HttpServletResponse response, - + UriComponentsBuilder uriBuilder ) throws IOException; } \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index b8d97dca..404126ef 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -1,18 +1,29 @@ +/* + * Copyright (c) 2024 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package edu.kit.datamanager.pit.web.impl; +import edu.kit.datamanager.entities.messaging.PidRecordMessage; import edu.kit.datamanager.exceptions.CustomInternalServerError; -import java.io.IOException; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Optional; -import java.util.stream.Stream; - +import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.common.PidAlreadyExistsException; import edu.kit.datamanager.pit.common.PidNotFoundException; +import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.configuration.PidGenerationProperties; -import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.domain.PIDRecord; import edu.kit.datamanager.pit.elasticsearch.PidRecordElasticRepository; import edu.kit.datamanager.pit.elasticsearch.PidRecordElasticWrapper; @@ -24,13 +35,10 @@ import edu.kit.datamanager.pit.web.ITypingRestResource; import edu.kit.datamanager.pit.web.TabulatorPaginationFormat; import edu.kit.datamanager.service.IMessagingService; -import edu.kit.datamanager.entities.messaging.PidRecordMessage; import edu.kit.datamanager.util.AuthenticationHelper; import edu.kit.datamanager.util.ControllerUtils; import io.swagger.v3.oas.annotations.media.Schema; - import jakarta.servlet.http.HttpServletResponse; - import org.apache.commons.lang3.stream.Streams; import org.apache.http.client.cache.HeaderConstants; import org.slf4j.Logger; @@ -48,19 +56,22 @@ import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.util.UriComponentsBuilder; +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Stream; + @RestController @RequestMapping(value = "/api/v1/pit") @Schema(description = "PID Information Types API") public class TypingRESTResourceImpl implements ITypingRestResource { private static final Logger LOG = LoggerFactory.getLogger(TypingRESTResourceImpl.class); - - @Autowired - private ApplicationProperties applicationProps; - @Autowired protected ITypingService typingService; - + @Autowired + private ApplicationProperties applicationProps; @Autowired private IMessagingService messagingService; @@ -80,6 +91,88 @@ public TypingRESTResourceImpl() { super(); } + @Override + public ResponseEntity> createPIDs( + List rec, + boolean dryrun, + WebRequest request, + HttpServletResponse response, + UriComponentsBuilder uriBuilder + ) throws IOException, RecordValidationException, ExternalServiceException { + LOG.info("Creating PIDs"); + String prefix = this.typingService.getPrefix().orElseThrow(() -> new IOException("No prefix configured.")); + + // Generate a map between temporary (user-defined) PIDs and final PIDs (generated) + Map pidMappings = new HashMap<>(); + for (PIDRecord pidRecord : rec) { + String internalPID = pidRecord.getPid(); + if (!internalPID.isBlank() && pidMappings.containsKey(internalPID)) { + // This internal PID was already used by some other record in the same request. + throw new RecordValidationException(pidRecord, "The PID " + internalPID + " was used for multiple records in the same request."); + } + + pidRecord.setPid(""); + if (dryrun) { + pidRecord.setPid("dryrun_" + pidMappings.size()); + } else { + setPid(pidRecord); + } + pidMappings.put(internalPID, pidRecord.getPid()); + } + + List validatedRecords = new ArrayList<>(); + for (PIDRecord pidRecord : rec) { + + // use this map to replace all temporary PIDs in the record values with their corresponding real PIDs + pidRecord.getEntries().values().stream() + .flatMap(List::stream) + .filter(entry -> entry.getValue() != null) + .filter(entry -> pidMappings.containsKey(entry.getValue())) + .peek(entry -> LOG.debug("Found reference. Replacing {} with {}.", entry.getValue(), prefix + pidMappings.get(entry.getValue()))) + .forEach(entry -> entry.setValue(prefix + pidMappings.get(entry.getValue()))); + + // validate the record + this.typingService.validate(pidRecord); + + // store the record + validatedRecords.add(pidRecord); + } + + if (dryrun) { + return ResponseEntity.status(HttpStatus.OK).body(validatedRecords); + } + + // register the records + validatedRecords.forEach(pidRecord -> { + // register the PID + String pid = this.typingService.registerPID(pidRecord); + pidRecord.setPid(pid); + + // store pid locally in accordance with the storage strategy + if (applicationProps.getStorageStrategy().storesModified()) { + storeLocally(pid, true); + } + + // distribute pid creation event to other services + PidRecordMessage message = PidRecordMessage.creation( + pid, + "", // TODO parameter is depricated and will be removed soon. + AuthenticationHelper.getPrincipal(), + ControllerUtils.getLocalHostname()); + try { + this.messagingService.send(message); + } catch (Exception e) { + LOG.error("Could not notify messaging service about the following message: {}", message); + } + + // save the record to elastic + this.saveToElastic(pidRecord); + }); + + // return the created records + return ResponseEntity.status(HttpStatus.CREATED).body(validatedRecords); + } + @Override public ResponseEntity createPID( PIDRecord pidRecord, @@ -149,7 +242,7 @@ private void setPid(PIDRecord pidRecord) throws IOException { Stream suffixStream = suffixGenerator.infiniteStream(); Optional maybeSuffix = Streams.failableStream(suffixStream) - // With failible streams, we can throw exceptions. + // With failable streams, we can throw exceptions. .filter(suffix -> !this.typingService.isIdentifierRegistered(suffix)) .stream() // back to normal java streams .findFirst(); // as the stream is infinite, we should always find a prefix. @@ -169,10 +262,10 @@ public ResponseEntity updatePID( String pidInternal = pidRecord.getPid(); if (hasPid(pidRecord) && !pid.equals(pidInternal)) { throw new RecordValidationException( - pidRecord, - "PID in record was given, but it was not the same as the PID in the URL. Ignore request, assuming this was not intended."); + pidRecord, + "PID in record was given, but it was not the same as the PID in the URL. Ignore request, assuming this was not intended."); } - + PIDRecord existingRecord = this.typingService.queryAllProperties(pid); if (existingRecord == null) { throw new PidNotFoundException(pid); @@ -206,7 +299,7 @@ public ResponseEntity updatePID( /** * Stores the PID in a local database. - * + * * @param pid the PID * @param update if true, updates the modified timestamp if it already exists. * If it does not exist, it will be created with both timestamps @@ -255,9 +348,9 @@ public ResponseEntity getRecord( private void saveToElastic(PIDRecord rec) { this.elastic.ifPresent( - database -> database.save( - new PidRecordElasticWrapper(rec, typingService.getOperations()) - ) + database -> database.save( + new PidRecordElasticWrapper(rec, typingService.getOperations()) + ) ); } @@ -276,11 +369,11 @@ public ResponseEntity findByPid( } public Page findAllPage( - Instant createdAfter, - Instant createdBefore, - Instant modifiedAfter, - Instant modifiedBefore, - Pageable pageable + Instant createdAfter, + Instant createdBefore, + Instant modifiedAfter, + Instant modifiedBefore, + Pageable pageable ) { final boolean queriesCreated = createdAfter != null || createdBefore != null; final boolean queriesModified = modifiedAfter != null || modifiedBefore != null; @@ -301,11 +394,11 @@ public Page findAllPage( Page resultModifiedTimestamp = Page.empty(); if (queriesCreated) { resultCreatedTimestamp = this.localPidStorage - .findDistinctPidsByCreatedBetween(createdAfter, createdBefore, pageable); + .findDistinctPidsByCreatedBetween(createdAfter, createdBefore, pageable); } if (queriesModified) { resultModifiedTimestamp = this.localPidStorage - .findDistinctPidsByModifiedBetween(modifiedAfter, modifiedBefore, pageable); + .findDistinctPidsByModifiedBetween(modifiedAfter, modifiedBefore, pageable); } if (queriesCreated && queriesModified) { final Page tmp = resultModifiedTimestamp; @@ -328,15 +421,14 @@ public ResponseEntity> findAll( Pageable pageable, WebRequest request, HttpServletResponse response, - UriComponentsBuilder uriBuilder) throws IOException - { + UriComponentsBuilder uriBuilder) throws IOException { Page page = this.findAllPage(createdAfter, createdBefore, modifiedAfter, modifiedBefore, pageable); response.addHeader( - HeaderConstants.CONTENT_RANGE, - ControllerUtils.getContentRangeHeader( - page.getNumber(), - page.getSize(), - page.getTotalElements())); + HeaderConstants.CONTENT_RANGE, + ControllerUtils.getContentRangeHeader( + page.getNumber(), + page.getSize(), + page.getTotalElements())); return ResponseEntity.ok().body(page.getContent()); } @@ -349,15 +441,14 @@ public ResponseEntity> findAllForTabular( Pageable pageable, WebRequest request, HttpServletResponse response, - UriComponentsBuilder uriBuilder) throws IOException - { + UriComponentsBuilder uriBuilder) throws IOException { Page page = this.findAllPage(createdAfter, createdBefore, modifiedAfter, modifiedBefore, pageable); response.addHeader( - HeaderConstants.CONTENT_RANGE, - ControllerUtils.getContentRangeHeader( - page.getNumber(), - page.getSize(), - page.getTotalElements())); + HeaderConstants.CONTENT_RANGE, + ControllerUtils.getContentRangeHeader( + page.getNumber(), + page.getSize(), + page.getTotalElements())); TabulatorPaginationFormat tabPage = new TabulatorPaginationFormat<>(page); return ResponseEntity.ok().body(tabPage); } From b9d0da41d25776f56c8c8b67ac39b7a2728a92c7 Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Tue, 17 Dec 2024 10:11:24 +0100 Subject: [PATCH 013/108] added more information --- .../pit/web/ITypingRestResource.java | 25 +++++++++++-------- .../pit/web/impl/TypingRESTResourceImpl.java | 10 +++++--- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java index 3f2883a0..60ddd2b0 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java +++ b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java @@ -49,6 +49,11 @@ public interface ITypingRestResource { * Create multiple, possibly related PID records using the record information. * This endpoint is a convenience method to create multiple PID records at once. * For connecting records, the PID fields must be specified and the value may be used in the value fields of other PIDRecordEntries. + * The provided PIDs will be overwritten as defined by the PID generator strategy. + *

+ * Note: This endpoint does not support custom PIDs, as the PID field is used for "imaginary" PIDs to connect records. + * These "imaginary" PIDs will be overwritten with actual, resolvable PIDs as defined by the PID generator strategy. + * If you want to create a record with custom PIDs, use the endpoint `POST /pid`. * * @param rec A list of PID records. * @param dryrun If true, only validation will be done and no PIDs will be created. No data will be changed and no services will be notified. @@ -57,29 +62,27 @@ public interface ITypingRestResource { * @throws edu.kit.datamanager.pit.common.RecordValidationException if any of the records is invalid or a PID was used for multiple records in the same request. */ @PostMapping( - path = "pids/", - consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, - produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} + path = "pids", + consumes = {MediaType.APPLICATION_JSON_VALUE}, + produces = {MediaType.APPLICATION_JSON_VALUE} ) @Operation( summary = "Create a multiple, possibly related PID records", - description = "Create multiple, possibly related PID records using the record information from the request body. To connect records the PID fields must be specified and the value may be used in the value fields of other PID Record entries. The provided PIDs will be overwritten as defined by the generator strategy." + description = "Create multiple, possibly related PID records using the record information from the request body. To connect records, the PID fields must be specified. This 'imaginary' PID value may then be used in the value fields of other PID Record entries. During creation, these `imaginary` PIDs whose sole purpose is to connect records will be overwritten with actual, resolvable PIDs as defined by the PID generator strategy. Note: This procedure does not support custom PIDs, as the PID field is used for linking records. If you want to create a record with custom PIDs, use the endpoint `POST /pid`." ) @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "The body containing a list of all PID record values as they should be in the new PID records.", + description = "The body containing a list of all PID record values as they should be in the new PID records. To connect records, the PID fields must be specified. This 'imaginary' PID value may then be used in the value fields of other PID Record entries. During creation, these `imaginary` PIDs whose sole purpose is to connect records will be overwritten with actual, resolvable PIDs as defined by the PID generator strategy.", required = true, content = { - @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, array = @ArraySchema(schema = @Schema(implementation = PIDRecord.class))), - @Content(mediaType = SimplePidRecord.CONTENT_TYPE, array = @ArraySchema(schema = @Schema(implementation = SimplePidRecord.class))) + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, array = @ArraySchema(schema = @Schema(implementation = PIDRecord.class))) } ) @ApiResponses(value = { @ApiResponse( responseCode = "201", - description = "Created", + description = "Successfully created all records and resolved references (if they exist). The response contains the created records.", content = { - @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, array = @ArraySchema(schema = @Schema(implementation = PIDRecord.class))), - @Content(mediaType = SimplePidRecord.CONTENT_TYPE, array = @ArraySchema(schema = @Schema(implementation = SimplePidRecord.class))) + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, array = @ArraySchema(schema = @Schema(implementation = PIDRecord.class))) }), @ApiResponse(responseCode = "400", description = "Validation failed. See body for details. Contains also the validated records.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), @ApiResponse(responseCode = "406", description = "Provided input is invalid with regard to the supported accept header (Not acceptable)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), @@ -119,7 +122,7 @@ ResponseEntity> createPIDs( * @throws IOException */ @PostMapping( - path = "pid/", + path = "pid", consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} ) diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index 404126ef..524e2195 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -66,8 +66,8 @@ @RequestMapping(value = "/api/v1/pit") @Schema(description = "PID Information Types API") public class TypingRESTResourceImpl implements ITypingRestResource { - private static final Logger LOG = LoggerFactory.getLogger(TypingRESTResourceImpl.class); + @Autowired protected ITypingService typingService; @Autowired @@ -99,7 +99,7 @@ public ResponseEntity> createPIDs( HttpServletResponse response, UriComponentsBuilder uriBuilder ) throws IOException, RecordValidationException, ExternalServiceException { - LOG.info("Creating PIDs"); + LOG.info("Creating PIDs for {} records.", rec.size()); String prefix = this.typingService.getPrefix().orElseThrow(() -> new IOException("No prefix configured.")); // Generate a map between temporary (user-defined) PIDs and final PIDs (generated) @@ -136,9 +136,11 @@ public ResponseEntity> createPIDs( // store the record validatedRecords.add(pidRecord); + LOG.debug("Record {} is valid.", pidRecord); } if (dryrun) { + LOG.info("Dryrun finished. Returning validated records for {} records.", validatedRecords.size()); return ResponseEntity.status(HttpStatus.OK).body(validatedRecords); } @@ -156,7 +158,7 @@ public ResponseEntity> createPIDs( // distribute pid creation event to other services PidRecordMessage message = PidRecordMessage.creation( pid, - "", // TODO parameter is depricated and will be removed soon. + "", // TODO parameter is deprecated and will be removed soon. AuthenticationHelper.getPrincipal(), ControllerUtils.getLocalHostname()); try { @@ -170,6 +172,7 @@ public ResponseEntity> createPIDs( }); // return the created records + LOG.info("Creation finished. Returning validated records for {} records.", validatedRecords.size()); return ResponseEntity.status(HttpStatus.CREATED).body(validatedRecords); } @@ -456,5 +459,4 @@ public ResponseEntity> findAllForTabular( private String quotedEtag(PIDRecord pidRecord) { return String.format("\"%s\"", pidRecord.getEtag()); } - } From 0695cc63299e82b19bc9142d047eae59a97cfa2c Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Fri, 3 Jan 2025 19:18:08 +0100 Subject: [PATCH 014/108] feat: read additionalAttributes allowed from profiles and consider them for validation --- .../configuration/ApplicationProperties.java | 16 ++++++--------- .../impl/EmbeddedStrictValidatorStrategy.java | 15 +++++++------- .../pit/typeregistry/RegisteredProfile.java | 7 +++++-- .../pit/typeregistry/impl/TypeApi.java | 20 ++++++++++++++++++- 4 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java b/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java index 18dd5f03..c714b959 100644 --- a/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java +++ b/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java @@ -27,6 +27,8 @@ import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -119,8 +121,10 @@ public boolean storesResolved() { @Deprecated(forRemoval = true /*In Typed PID Maker 3.0.0*/) private String profileKey; - @Value("${pit.validation.allowAdditionalAttributes:true}") - private boolean allowAdditionalAttributes = true; + @Getter + @Setter + @Value("${pit.validation.alwaysAllowAdditionalAttributes:true}") + private boolean alwaysAllowAdditionalAttributes = true; @Value("#{${pit.validation.profileKeys:{}}}") @NotNull @@ -202,12 +206,4 @@ public StorageStrategy getStorageStrategy() { public void setStorageStrategy(StorageStrategy storageStrategy) { this.storageStrategy = storageStrategy; } - - public boolean isAllowAdditionalAttributes() { - return allowAdditionalAttributes; - } - - public void setAllowAdditionalAttributes(boolean allowAdditionalAttributes) { - this.allowAdditionalAttributes = allowAdditionalAttributes; - } } diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java index 8564649d..33b2e30f 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java @@ -28,9 +28,9 @@ public class EmbeddedStrictValidatorStrategy implements IValidationStrategy { private static final Logger LOG = LoggerFactory.getLogger(EmbeddedStrictValidatorStrategy.class); protected static final Executor EXECUTOR = Executors.newWorkStealingPool(); - protected ITypeRegistry typeRegistry; - protected boolean additionalAttributesAllowed; - protected Set profileKeys; + protected final ITypeRegistry typeRegistry; + protected final boolean alwaysAcceptAdditionalAttributes; + protected final Set profileKeys; public EmbeddedStrictValidatorStrategy( ITypeRegistry typeRegistry, @@ -38,6 +38,7 @@ public EmbeddedStrictValidatorStrategy( ) { this.typeRegistry = typeRegistry; this.profileKeys = config.getProfileKeys(); + this.alwaysAcceptAdditionalAttributes = config.isAlwaysAllowAdditionalAttributes(); } @Override @@ -67,12 +68,12 @@ public void validate(PIDRecord pidRecord) })) // resolve profiles and apply their validation .map(attributeInfoFuture -> attributeInfoFuture.thenApply(attributeInfo -> { - boolean isProfile = this.profileKeys.contains(attributeInfo.pid()); - if (isProfile) { + boolean indicatesProfileValue = this.profileKeys.contains(attributeInfo.pid()); + if (indicatesProfileValue) { Arrays.stream(pidRecord.getPropertyValues(attributeInfo.pid())) - .map(profilePid -> this.typeRegistry.queryAsProfile(profilePid)) + .map(this.typeRegistry::queryAsProfile) .forEach(registeredProfileFuture -> registeredProfileFuture.thenApply(registeredProfile -> { - registeredProfile.validateAttributes(pidRecord, this.additionalAttributesAllowed); + registeredProfile.validateAttributes(pidRecord, this.alwaysAcceptAdditionalAttributes); return registeredProfile; })); } diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java index 110de8b3..d6c0f689 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java @@ -10,16 +10,19 @@ public record RegisteredProfile( String pid, + boolean allowAdditionalAttributes, ImmutableList attributes ) { - public void validateAttributes(PIDRecord pidRecord, boolean allowAdditionalAttributes) { + public void validateAttributes(PIDRecord pidRecord, boolean alwaysAllowAdditionalAttributes) { Set additionalAttributes = pidRecord.getPropertyIdentifiers().stream() .filter(recordKey -> attributes.items().stream().anyMatch( profileAttribute -> Objects.equals(profileAttribute.pid(), recordKey))) .collect(Collectors.toSet()); - boolean violatesAdditionalAttributes = !allowAdditionalAttributes && !additionalAttributes.isEmpty(); + + boolean additionalAttributesForbidden = !this.allowAdditionalAttributes && !alwaysAllowAdditionalAttributes; + boolean violatesAdditionalAttributes = additionalAttributesForbidden && !additionalAttributes.isEmpty(); if (violatesAdditionalAttributes) { throw new RecordValidationException( pidRecord, diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java index 7189448e..362fad0f 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java @@ -30,6 +30,7 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.stream.StreamSupport; public class TypeApi implements ITypeRegistry { @@ -164,7 +165,24 @@ protected RegisteredProfile extractProfileInformation(String profilePid, JsonNod }); - return new RegisteredProfile(profilePid, new ImmutableList<>(attributes)); + boolean additionalAttributesDtrTestStyle = StreamSupport.stream(typeApiResponse + .path("content") + .path("representationsAndSemantics") + .spliterator(), + true) + .filter(JsonNode::isObject) + .filter(node -> node.path("expression").asText("").equals("Format")) + .map(node -> node.path("subSchemaRelation").asText("").equals("allowAdditionalProperties")) + .findFirst() + .orElse(true); + boolean additionalAttributesEoscStyle = typeApiResponse + .path("content") + .path("addProps") + .asBoolean(true); + // As the default is true, we assume that additional attributes are disallowed if one indicator is false: + boolean profileDefinitionAllowsAdditionalAttributes = additionalAttributesDtrTestStyle && additionalAttributesEoscStyle; + + return new RegisteredProfile(profilePid, profileDefinitionAllowsAdditionalAttributes, new ImmutableList<>(attributes)); } @Override From 854490983b47b6911c26f5e1c8ca88e7c1741682 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 7 Jan 2025 10:34:26 +0100 Subject: [PATCH 015/108] fix: throw error on PID not found --- .../pit/common/PidNotFoundException.java | 3 +++ .../pit/pidsystem/impl/HandleProtocolAdapter.java | 5 ++--- .../pit/pidsystem/IIdentifierSystemQueryTest.java | 13 +++---------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/common/PidNotFoundException.java b/src/main/java/edu/kit/datamanager/pit/common/PidNotFoundException.java index a72d4337..9131a2a3 100644 --- a/src/main/java/edu/kit/datamanager/pit/common/PidNotFoundException.java +++ b/src/main/java/edu/kit/datamanager/pit/common/PidNotFoundException.java @@ -17,4 +17,7 @@ public PidNotFoundException(String pid) { super(HTTP_STATUS, "Identifier with value " + pid + " not found."); } + public PidNotFoundException(String pid, Throwable e) { + super(HTTP_STATUS, "Identifier with value " + pid + " not found.", e); + } } diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java index daadc473..ec516042 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java @@ -177,12 +177,11 @@ public PIDRecord queryPid(final String pid) throws PidNotFoundException, Externa protected Collection queryAllHandleValues(final String pid) throws PidNotFoundException, ExternalServiceException { try { HandleValue[] values = this.client.resolveHandle(pid, null, null); - return Stream - .of(values) + return Stream.of(values) .collect(Collectors.toCollection(ArrayList::new)); } catch (HandleException e) { if (e.getCode() == HandleException.HANDLE_DOES_NOT_EXIST) { - return new ArrayList<>(); + throw new PidNotFoundException(pid, e); } else { throw new ExternalServiceException(SERVICE_NAME_HANDLE, e); } diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java index 78142ff4..791f1d19 100644 --- a/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java @@ -85,8 +85,9 @@ public void queryPidExample(IIdentifierSystem impl, String pid) throws IOExcepti @ParameterizedTest @MethodSource("implProvider") public void queryPidOfNonexistent(IIdentifierSystem impl, String _pid, String pid_nonexist) throws IOException { - PIDRecord result = impl.queryPid(pid_nonexist); - assertNull(result); + assertThrows(PidNotFoundException.class, () -> { + impl.queryPid(pid_nonexist); + }); } @ParameterizedTest @@ -106,12 +107,4 @@ public void queryNonexistentProperty(IIdentifierSystem impl, String pid) throws PIDRecord record = impl.queryPid(pid); assertFalse(record.getPropertyIdentifiers().contains("Nonexistent_Attribute")); } - - @ParameterizedTest - @MethodSource("implProvider") - public void queryPropertyOfNonexistent(IIdentifierSystem impl, String pid, String pid_nonexist) throws IOException { - assertThrows(PidNotFoundException.class, () -> { - impl.queryPid(pid_nonexist); - }); - } } From 7938072207e0f029c953d8db287bf33fa4d06f47 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 7 Jan 2025 10:34:52 +0100 Subject: [PATCH 016/108] fix: missing slash after type api endpoint --- .../java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java index 362fad0f..53f8c8ba 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java @@ -48,7 +48,7 @@ public TypeApi(ApplicationProperties properties, SchemaSetGenerator schemaSetGen this.baseUrl = properties.getTypeRegistryUri(); String baseUri; try { - baseUri = baseUrl.toURI().resolve("v1/types").toString(); + baseUri = baseUrl.toURI().resolve("v1/types/").toString(); } catch (URISyntaxException e) { throw new InvalidConfigException("Type-Api base url not valid: " + baseUrl); } From 85db2a0bb6a37f59859cd62f022aaf897a7dcefe Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 7 Jan 2025 10:35:23 +0100 Subject: [PATCH 017/108] fix(tests): check assumptions in etag test setup --- src/test/java/edu/kit/datamanager/pit/web/EtagTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/test/java/edu/kit/datamanager/pit/web/EtagTest.java b/src/test/java/edu/kit/datamanager/pit/web/EtagTest.java index c552cb3a..f30e3220 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/EtagTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/EtagTest.java @@ -4,6 +4,10 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import edu.kit.datamanager.pit.SpringTestHelper; +import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; +import edu.kit.datamanager.pit.pidsystem.impl.local.LocalPidSystem; import org.apache.http.HttpHeaders; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -53,6 +57,11 @@ class EtagTest { void setup() throws Exception { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext).build(); + SpringTestHelper springTestHelper = new SpringTestHelper(this.webApplicationContext); + springTestHelper.assertSingleBeanInstanceOf(InMemoryIdentifierSystem.class); + springTestHelper.assertNoBeanInstanceOf(LocalPidSystem.class); + springTestHelper.assertNoBeanInstanceOf(HandleProtocolAdapter.class); + MockHttpServletResponse response = ApiMockUtils.registerSomeRecordAndReturnMvcResult(this.mockMvc).getResponse(); String etagHeader = response.getHeader(HttpHeaders.ETAG); this.existingRecord = ApiMockUtils.deserializeRecord(response); From e7361f41bd6fc5e8d5e4e76277d116d671732029 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 7 Jan 2025 18:56:56 +0100 Subject: [PATCH 018/108] fix(tests): type api test for complex type now properly reflects the current state of the APIs --- .../typeregistry/schema/DtrTestSchemaGenerator.java | 11 ++++++----- .../pit/typeregistry/impl/TypeApiTest.java | 13 +++++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java index 533623e0..1d6b4876 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java @@ -22,6 +22,7 @@ public class DtrTestSchemaGenerator implements SchemaGenerator { + protected static final String ORIGIN = "dtr-test"; protected final URI baseUrl; protected final HttpClient http; @@ -52,21 +53,21 @@ public SchemaInfo generateSchema(@NotNull String maybeTypePid) { schema = SchemaLoader.load(rawSchema); } catch (JSONException e) { return new SchemaInfo( - this.baseUrl.toString(), + ORIGIN, schema, new ExternalServiceException(baseUrl.toString(), "No valid schema found resolving PID " + maybeTypePid) ); } - return new SchemaInfo(this.baseUrl.toString(), schema, null); + return new SchemaInfo(ORIGIN, schema, null); } else if (statusCode == 404) { return new SchemaInfo( - this.baseUrl.toString(), + ORIGIN, null, new TypeNotFoundException(maybeTypePid) ); } else { return new SchemaInfo( - this.baseUrl.toString(), + ORIGIN, null, new ExternalServiceException( this.baseUrl.toString(), @@ -77,7 +78,7 @@ public SchemaInfo generateSchema(@NotNull String maybeTypePid) { ); } catch (IOException e) { return new SchemaInfo( - this.baseUrl.toString(), + ORIGIN, null, new ExternalServiceException(baseUrl.toString(), "Error communicating with service.", e) ); diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java index c645cd7a..1567e84c 100644 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java @@ -8,6 +8,7 @@ import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; +import java.util.Optional; import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -31,9 +32,17 @@ class TypeApiTest { @Test void querySchemaOfComplexType() { + // NOTE The new Type-API currently returns a malformed schema for the + // checksum type in dtr-test. This test ensures that we at least get + // the legacy schema in this case. If this test breaks, we either + // have no schema, or the type-api is fixed. Set s = dtr.querySchemas(PID_COMPLEX_TYPE_CHECKSUM_DTRTEST); assertEquals(2, s.size()); - // TODO this test is just for proof of concept testing. It does not mean the two schemas were retrieved successfully. - // This is due to error information also being collected within a SchemaInfo type for later use. + Optional result = s.stream() + .filter(schemaInfo -> schemaInfo.schema() != null) + .filter(schemaInfo -> schemaInfo.error() == null) + .filter(schemaInfo -> schemaInfo.origin().contains("dtr-test")) + .findAny(); + assertTrue(result.isPresent()); } } \ No newline at end of file From 18a06ba0eb36709b048c4f5a9bd111ec14b26f45 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 7 Jan 2025 19:28:31 +0100 Subject: [PATCH 019/108] test: some general profile attributes with type API --- .../pit/typeregistry/impl/TypeApiTest.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java index 1567e84c..aafccb23 100644 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java @@ -1,6 +1,7 @@ package edu.kit.datamanager.pit.typeregistry.impl; import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import edu.kit.datamanager.pit.typeregistry.AttributeInfo; import edu.kit.datamanager.pit.typeregistry.schema.SchemaInfo; import edu.kit.datamanager.pit.typeregistry.schema.SchemaSetGenerator; import org.junit.jupiter.api.Test; @@ -30,6 +31,28 @@ class TypeApiTest { this.dtr = new TypeApi(props, new SchemaSetGenerator(props)); } + @Test + void queryAttributeInfoOfSimpleType() { + String attributePid = "21.T11148/b8457812905b83046284"; + AttributeInfo info = dtr.queryAttributeInfo(attributePid).join(); + assertEquals(attributePid, info.pid()); + assertFalse(info.jsonSchema().isEmpty()); + assertEquals(2, info.jsonSchema().size()); + assertTrue(info.name().contains("Location")); + assertEquals("PID-InfoType", info.typeName()); + } + + @Test + void queryAttributeInfoOfComplexType() { + AttributeInfo info = dtr.queryAttributeInfo(PID_COMPLEX_TYPE_CHECKSUM_DTRTEST).join(); + assertEquals(PID_COMPLEX_TYPE_CHECKSUM_DTRTEST, info.pid()); + assertFalse(info.jsonSchema().isEmpty()); + assertTrue(info.name().contains("checksum")); + assertEquals("PID-InfoType", info.typeName()); + } + + /* ================== TESTING INTERNALS ================== */ + @Test void querySchemaOfComplexType() { // NOTE The new Type-API currently returns a malformed schema for the From 69b0d576c37d01aeb3b82efa204291b22fd9e19a Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 8 Jan 2025 10:22:29 +0100 Subject: [PATCH 020/108] cleanup: remove unused domain classes --- .../datamanager/pit/domain/Contributor.java | 22 ------------ .../pit/domain/PIDRecordEntry.java | 10 ------ .../pit/domain/ProvenanceInformation.java | 34 ------------------- 3 files changed, 66 deletions(-) delete mode 100644 src/main/java/edu/kit/datamanager/pit/domain/Contributor.java delete mode 100644 src/main/java/edu/kit/datamanager/pit/domain/ProvenanceInformation.java diff --git a/src/main/java/edu/kit/datamanager/pit/domain/Contributor.java b/src/main/java/edu/kit/datamanager/pit/domain/Contributor.java deleted file mode 100644 index 26e0c3cf..00000000 --- a/src/main/java/edu/kit/datamanager/pit/domain/Contributor.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package edu.kit.datamanager.pit.domain; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import lombok.Data; - -/** - * - * @author Torridity - */ -@Data -@JsonIgnoreProperties(ignoreUnknown = true) -public class Contributor { - - private String identifiedUsing; - private String name; - private String details; -} diff --git a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java index dc7233b3..c124f3ec 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java @@ -1,17 +1,7 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ package edu.kit.datamanager.pit.domain; -import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Data; -/** - * - * @author Torridity - */ @Data public class PIDRecordEntry { private String key; diff --git a/src/main/java/edu/kit/datamanager/pit/domain/ProvenanceInformation.java b/src/main/java/edu/kit/datamanager/pit/domain/ProvenanceInformation.java deleted file mode 100644 index 23a32811..00000000 --- a/src/main/java/edu/kit/datamanager/pit/domain/ProvenanceInformation.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package edu.kit.datamanager.pit.domain; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.util.Date; -import java.util.HashSet; -import java.util.Set; -import lombok.Data; - -/** - * - * @author Torridity - */ -@Data -@JsonIgnoreProperties(ignoreUnknown = true) -public class ProvenanceInformation { - - private Set contributors = new HashSet<>(); - private Date creationDate; - private Date lastModificationDate; - - public void addContributor(String identifiedBy, String name, String details) { - Contributor c = new Contributor(); - c.setIdentifiedUsing(identifiedBy); - c.setName(name); - c.setDetails(details); - contributors.add(c); - } - -} From aaba94db352927a418ddf948e94071b9fd650108 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 8 Jan 2025 15:10:42 +0100 Subject: [PATCH 021/108] fix: apply json schema validation correctly --- .../pit/typeregistry/AttributeInfo.java | 7 ++- .../schema/SchemaSetGenerator.java | 4 +- .../schema/SchemaSetGeneratorTest.java | 46 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java index ef4207ae..9e4b1a27 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java @@ -3,6 +3,7 @@ import edu.kit.datamanager.pit.typeregistry.schema.SchemaInfo; import org.everit.json.schema.Schema; import org.everit.json.schema.ValidationException; +import org.json.JSONObject; import java.util.Collection; import java.util.Objects; @@ -29,8 +30,12 @@ public boolean validate(String value) { } private boolean validate(Schema schema, String value) { + Object toValidate = value; + if (value.startsWith("{")) { + toValidate = new JSONObject(value); + } try { - schema.validate(value); + schema.validate(toValidate); } catch (ValidationException e) { return false; } diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java index 3b8a7410..bbe3af1e 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java @@ -12,8 +12,8 @@ import java.util.stream.Collectors; public class SchemaSetGenerator { - private final Set GENERATORS; - private final AsyncLoadingCache> CACHE; + protected final Set GENERATORS; + protected final AsyncLoadingCache> CACHE; public SchemaSetGenerator(ApplicationProperties props) { GENERATORS = Set.of( diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java new file mode 100644 index 00000000..0d9addbe --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java @@ -0,0 +1,46 @@ +package edu.kit.datamanager.pit.typeregistry.schema; + +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import edu.kit.datamanager.pit.typeregistry.AttributeInfo; +import org.everit.json.schema.Schema; +import org.everit.json.schema.ValidationException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +class SchemaSetGeneratorTest { + + static ApplicationProperties properties; + static SchemaSetGenerator generator; + + @BeforeAll + static void setup() throws Exception { + properties = new ApplicationProperties(); + properties.setExpireAfterWrite(10); + properties.setMaximumSize(1000); + properties.setTypeRegistryUri(new URI("https://typeapi.lab.pidconsortium.net").toURL()); + properties.setHandleBaseUri(new URI("https://hdl.handle.net").toURL()); + generator = new SchemaSetGenerator(properties); + } + + /** + * @throws ValidationException if a schema fails to validate + * @throws NoSuchElementException if no schema is found + */ + @Test + void testChecksumValidation() throws ValidationException, NoSuchElementException { + // generated test for this error message: Reason:\nAttribute 21.T11148/92e200311a56800b3e47 has a non-complying value { \"sha256sum\": \"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\" } + Set schemaInfos = generator.generateFor("21.T11148/92e200311a56800b3e47").join(); + AttributeInfo attributeInfo = new AttributeInfo("21.T11148/92e200311a56800b3e47", "name", "typeName", schemaInfos); + assertTrue(attributeInfo.validate("{ \"sha256sum\": \"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\" }")); + // This is currently not supported, but would be nice to have: + assertFalse(attributeInfo.validate("\"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\"")); + } +} \ No newline at end of file From c4d1aa281c61a62c345f1d3cbc374b2d4e97b874 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 8 Jan 2025 15:58:23 +0100 Subject: [PATCH 022/108] fix: a attribute which is not registered, shall being presented as a validation error to the user, not as a PidNotFoundError. --- .../impl/EmbeddedStrictValidatorStrategy.java | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java index 33b2e30f..ffbd00c8 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java @@ -2,6 +2,7 @@ import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.common.RecordValidationException; +import edu.kit.datamanager.pit.common.TypeNotFoundException; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.domain.PIDRecord; import edu.kit.datamanager.pit.pitservice.IValidationStrategy; @@ -85,10 +86,10 @@ public void validate(PIDRecord pidRecord) try { CompletableFuture.allOf(attributeInfoFutures.toArray(new CompletableFuture[0])).join(); } catch (CompletionException e) { - throwRecordValidationExceptionCause(e); + unpackAsyncExceptions(e); throw new ExternalServiceException(this.typeRegistry.getRegistryIdentifier()); } catch (CancellationException e) { - throwRecordValidationExceptionCause(e); + unpackAsyncExceptions(e); throw new RecordValidationException( pidRecord, String.format("Validation task was cancelled for %s. Please report.", pidRecord.getPid())); @@ -101,13 +102,24 @@ public void validate(PIDRecord pidRecord) * Usually used to avoid exposing exceptions related to futures. * @param e the exception to unwrap. */ - private static void throwRecordValidationExceptionCause(Throwable e) { + private static void unpackAsyncExceptions(Throwable e) { + unpackAsyncExceptions(e, 0); + } + + private static void unpackAsyncExceptions(Throwable e, int level) { Throwable cause = e.getCause(); + final int MAX_LEVEL = 10; + if (level > MAX_LEVEL || cause == null) { + return; + } if (cause instanceof RecordValidationException rve) { throw rve; - } else if (cause != null && cause.getCause() instanceof RecordValidationException rve) { - // in some cases we need to go deeper, because profiles are handled in a future within a future. - throw rve; + } else if (cause instanceof TypeNotFoundException tnf) { + throw new RecordValidationException( + new PIDRecord(), + "Type not found: %s".formatted(tnf.getMessage())); + } else { + unpackAsyncExceptions(cause, level + 1); } } } From 24c5bebe81fd3080a3c13ade3f20eeb0747b3622 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 8 Jan 2025 15:58:51 +0100 Subject: [PATCH 023/108] cleanup: apply linter suggestions --- .../pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java index ffbd00c8..e8128a8a 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java @@ -19,7 +19,7 @@ /** * Validates a PID record using embedded profile(s). - * + *

* - checks if all mandatory attributes are present * - validates all available attributes * - fails if an attribute is not defined within the profile @@ -27,7 +27,6 @@ public class EmbeddedStrictValidatorStrategy implements IValidationStrategy { private static final Logger LOG = LoggerFactory.getLogger(EmbeddedStrictValidatorStrategy.class); - protected static final Executor EXECUTOR = Executors.newWorkStealingPool(); protected final ITypeRegistry typeRegistry; protected final boolean alwaysAcceptAdditionalAttributes; @@ -53,7 +52,7 @@ public void validate(PIDRecord pidRecord) // For each attribute in record, resolve schema and check the value List> attributeInfoFutures = pidRecord.getPropertyIdentifiers().stream() // resolve attribute info (type and schema) - .map(attributePid -> this.typeRegistry.queryAttributeInfo(attributePid)) + .map(this.typeRegistry::queryAttributeInfo) // validate values using schema .map(attributeInfoFuture -> attributeInfoFuture.thenApply(attributeInfo -> { for (String value : pidRecord.getPropertyValues(attributeInfo.pid())) { From 186e4a8c6770f669379d2b445b22b6b66d4872df Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 8 Jan 2025 17:52:13 +0100 Subject: [PATCH 024/108] fix(tests): profiles which do not specify their handling of additional attributes, now default to true. --- .../pit/web/ExplicitValidationParametersTest.java | 8 ++++---- .../kit/datamanager/pit/web/RestWithInMemoryTest.java | 11 +++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java index 19b496e6..b7b648af 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java @@ -214,7 +214,7 @@ void testNontypeRecord() throws Exception { } @Test - void testInvalidRecordWithProfile() throws Exception { + void testRecordWithAdditionalAttribute() throws Exception { PIDRecord r = new PIDRecord(); r.addEntry("21.T11148/076759916209e5d62bd5", "for Testing", "21.T11148/301c6f04763a16f0f72a"); this.mockMvc @@ -227,9 +227,9 @@ void testInvalidRecordWithProfile() throws Exception { .accept(MediaType.ALL) ) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isBadRequest()); - - // we store PIDs only if the PID was created successfully + .andExpect(MockMvcResultMatchers.status().isOk()); + + // we store PIDs only if the PID was created (no dryrun) assertEquals(0, this.knownPidsDao.count()); } diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java index 45face57..85401760 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java @@ -191,7 +191,7 @@ void testNontypeRecord() throws Exception { } @Test - void testInvalidRecordWithProfile() throws Exception { + void testRecordWithAdditionalAttribute() throws Exception { PIDRecord r = new PIDRecord(); r.addEntry("21.T11148/076759916209e5d62bd5", "for Testing", "21.T11148/301c6f04763a16f0f72a"); MvcResult result = this.mockMvc @@ -203,13 +203,12 @@ void testInvalidRecordWithProfile() throws Exception { .accept(MediaType.ALL) ) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isBadRequest()) - .andExpect(MockMvcResultMatchers.jsonPath("$.detail", Matchers.containsString("Missing mandatory types: ["))) + .andExpect(MockMvcResultMatchers.status().isCreated()) .andReturn(); - // we store PIDs only if the PID was created successfully - assertEquals(0, this.knownPidsDao.count()); - // assume error parsed from body + // we store PIDs, if the PID was created successfully + assertEquals(1, this.knownPidsDao.count()); + // sanity check that body is not empty assertFalse(result.getResponse().getContentAsString().isEmpty()); } From e61ba54b9a8e2ab8b6fee0fe41229aa1f8a7e83f Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 8 Jan 2025 17:53:15 +0100 Subject: [PATCH 025/108] fix(tests): sanity check about the number of tests to execute with local pid system --- .../pit/pidsystem/impl/local/LocalPidSystemTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystemTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystemTest.java index 8407038d..23978f7e 100644 --- a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystemTest.java +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/local/LocalPidSystemTest.java @@ -78,7 +78,7 @@ void testAllSystemTests() throws Exception { Set publicMethods = new HashSet<>(Arrays.asList(IIdentifierSystemQueryTest.class.getMethods())); Set allDirectMethods = new HashSet<>(Arrays.asList(IIdentifierSystemQueryTest.class.getDeclaredMethods())); publicMethods.retainAll(allDirectMethods); - assertEquals(7, publicMethods.size()); + assertEquals(6, publicMethods.size()); for (Method test : publicMethods) { int numParams = test.getParameterCount(); if (numParams == 2) { From 4fd4543a7ce24a59a1b5fe549ed333c7d2ac49b5 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 8 Jan 2025 17:55:22 +0100 Subject: [PATCH 026/108] cleanup: apply linter suggestions --- .../pit/web/ExplicitValidationParametersTest.java | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java index b7b648af..f70f86c3 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java @@ -1,9 +1,5 @@ package edu.kit.datamanager.pit.web; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import jakarta.servlet.ServletContext; @@ -45,6 +41,7 @@ import org.hamcrest.Matchers; +import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -111,7 +108,7 @@ void checkTestSetup() { assertNotNull(this.inMemory); ServletContext servletContext = webApplicationContext.getServletContext(); assertNotNull(servletContext); - assertTrue(servletContext instanceof MockServletContext); + assertInstanceOf(MockServletContext.class, servletContext); SpringTestHelper springTestHelper = new SpringTestHelper(webApplicationContext); springTestHelper.assertSingleBeanInstanceOf(ITypingRestResource.class); @@ -145,7 +142,6 @@ void testExtensiveRecordDryRun() throws Exception { // as we use an in-memory data structure, lets not make it too large. int numAttributes = 100; int numValues = 100; - assertTrue(numAttributes * numValues > 256); PIDRecord r = RecordTestHelper.getFakePidRecord(numAttributes, numValues, "sandboxed/", pidGenerator); String rJson = ApiMockUtils.serialize(r); @@ -173,7 +169,6 @@ void testExtensiveRecordWithoutDryRun() throws Exception { // as we use an in-memory data structure, lets not make it too large. int numAttributes = 100; int numValues = 100; - assertTrue(numAttributes * numValues > 256); PIDRecord r = RecordTestHelper.getFakePidRecord(numAttributes, numValues, "sandboxed/", pidGenerator); String rJson = ApiMockUtils.serialize(r); @@ -238,7 +233,7 @@ void testRecordWithAdditionalAttribute() throws Exception { void testResolvingValidRecordWithValidation() throws Exception { this.testExtensiveRecordWithoutDryRun(); assertEquals(1, knownPidsDao.count()); - String validPid = knownPidsDao.findAll().iterator().next().getPid(); + String validPid = knownPidsDao.findAll().getFirst().getPid(); this.mockMvc .perform( get("/api/v1/pit/pid/" + validPid) @@ -255,7 +250,7 @@ void testResolvingValidRecordWithValidationFail() throws Exception { // note: this test disables validation... this.testExtensiveRecordWithoutDryRun(); assertEquals(1, knownPidsDao.count()); - String validPid = knownPidsDao.findAll().iterator().next().getPid(); + String validPid = knownPidsDao.findAll().getFirst().getPid(); PIDRecord r = inMemory.queryPid(validPid); r.addEntry("21.T11148/076759916209e5d62bd5", "", "21.T11148/b9b76f887845e32d29f7"); @@ -275,6 +270,6 @@ void testResolvingValidRecordWithValidationFail() throws Exception { .andExpect(MockMvcResultMatchers.status().isBadRequest()) .andExpect(MockMvcResultMatchers.jsonPath("$.detail", Matchers.containsString("Missing mandatory types: ["))) .andReturn(); - assertTrue(0 < result.getResponse().getContentAsString().length()); + assertFalse(result.getResponse().getContentAsString().isEmpty()); } } From 04bff17858c992299158aafa4b4be3492472378c Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 8 Jan 2025 17:55:57 +0100 Subject: [PATCH 027/108] docs: add note about why this test currently fails --- .../edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java index 3f788b94..08a7c6fb 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java @@ -90,6 +90,8 @@ void resolveSomething() throws Exception { @Test void testUpdateWithPidGiven() throws Exception { + // FIXME this test is currently not working as there is for some attributes no schema available in dtr-test, + // and the type-api does currently not work. String etag = this.mockMvc.perform( get("/api/v1/pit/pid/21.11152/474a4b1c-de93-4d4a-b33d-1d32d63baf4b?validation=false") ) From ef0242ec0a743c1836bdf5ad4ec8806991a95ec3 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Mon, 13 Jan 2025 17:29:10 +0100 Subject: [PATCH 028/108] fix: use different executor per cache --- src/main/java/edu/kit/datamanager/pit/Application.java | 5 ----- .../edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java | 5 +++-- .../pit/typeregistry/schema/SchemaSetGenerator.java | 3 ++- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/Application.java b/src/main/java/edu/kit/datamanager/pit/Application.java index af17cd7d..78ad39a8 100644 --- a/src/main/java/edu/kit/datamanager/pit/Application.java +++ b/src/main/java/edu/kit/datamanager/pit/Application.java @@ -38,8 +38,6 @@ import java.io.IOException; import java.util.Objects; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; import java.util.stream.Stream; import org.apache.http.client.HttpClient; @@ -88,9 +86,6 @@ public class Application { protected static final String ERROR_COMMUNICATION = "Communication error: {}"; protected static final String ERROR_CONFIGURATION = "Configuration error: {}"; - public static final Executor EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); - - @Bean @Scope("prototype") public Logger logger(InjectionPoint injectionPoint) { diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java index 53f8c8ba..5f4e6cef 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java @@ -29,6 +29,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.stream.StreamSupport; @@ -60,7 +61,7 @@ public TypeApi(ApplicationProperties properties, SchemaSetGenerator schemaSetGen this.profileCache = Caffeine.newBuilder() .maximumSize(maximumSize) - .executor(Application.EXECUTOR) + .executor(Executors.newVirtualThreadPerTaskExecutor()) .refreshAfterWrite(Duration.ofMinutes(expireAfterWrite / 2)) .expireAfterWrite(expireAfterWrite, TimeUnit.MINUTES) .removalListener((key, value, cause) -> @@ -73,7 +74,7 @@ public TypeApi(ApplicationProperties properties, SchemaSetGenerator schemaSetGen this.attributeCache = Caffeine.newBuilder() .maximumSize(maximumSize) - .executor(Application.EXECUTOR) + .executor(Executors.newVirtualThreadPerTaskExecutor()) .refreshAfterWrite(Duration.ofMinutes(expireAfterWrite / 2)) .expireAfterWrite(expireAfterWrite, TimeUnit.MINUTES) .removalListener((key, value, cause) -> diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java index bbe3af1e..25662e5a 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java @@ -8,6 +8,7 @@ import java.time.Duration; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -23,7 +24,7 @@ public SchemaSetGenerator(ApplicationProperties props) { CACHE = Caffeine.newBuilder() .maximumSize(props.getMaximumSize()) - .executor(Application.EXECUTOR) + .executor(Executors.newVirtualThreadPerTaskExecutor()) .refreshAfterWrite(Duration.ofMinutes(props.getExpireAfterWrite() / 2)) .expireAfterWrite(props.getExpireAfterWrite(), TimeUnit.MINUTES) .buildAsync(attributePid -> GENERATORS.stream() From 7fdf5547c89796679b7748258f7f10bcd134c63d Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Mon, 13 Jan 2025 17:30:40 +0100 Subject: [PATCH 029/108] refactor: use modern http client in dtr test schema generator --- .../schema/DtrTestSchemaGenerator.java | 86 +++++++++---------- 1 file changed, 39 insertions(+), 47 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java index 1d6b4876..7c6f66c5 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java @@ -5,26 +5,25 @@ import edu.kit.datamanager.pit.common.TypeNotFoundException; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import jakarta.validation.constraints.NotNull; -import org.apache.http.client.HttpClient; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.impl.client.LaxRedirectStrategy; import org.everit.json.schema.Schema; import org.everit.json.schema.loader.SchemaLoader; import org.json.JSONException; import org.json.JSONObject; import org.json.JSONTokener; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.web.client.RestClient; -import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; +import java.net.http.HttpClient; public class DtrTestSchemaGenerator implements SchemaGenerator { protected static final String ORIGIN = "dtr-test"; protected final URI baseUrl; - protected final HttpClient http; + protected final RestClient http; public DtrTestSchemaGenerator(@NotNull ApplicationProperties props) { try { @@ -32,56 +31,49 @@ public DtrTestSchemaGenerator(@NotNull ApplicationProperties props) { } catch (URISyntaxException e) { throw new InvalidConfigException("BaseUrl not configured properly."); } - this.http = HttpClientBuilder.create() - .setRedirectStrategy(new LaxRedirectStrategy()) + HttpClient httpClient = java.net.http.HttpClient.newBuilder() + .followRedirects(java.net.http.HttpClient.Redirect.NORMAL) + .build(); + this.http = RestClient.builder() + .baseUrl(this.baseUrl.toString()) + .requestFactory(new JdkClientHttpRequestFactory(httpClient)) .build(); } @Override public SchemaInfo generateSchema(@NotNull String maybeTypePid) { - HttpGet request = new HttpGet(baseUrl.resolve("/" + maybeTypePid)); - try { - return http.execute( - request, - response -> { - int statusCode = response.getStatusLine().getStatusCode(); - if (statusCode / 100 == 2) { - Schema schema = null; - try (InputStream inputStream = response.getEntity().getContent()) { - JSONObject jsonBody = new JSONObject(new JSONTokener(inputStream)); - JSONObject rawSchema = new JSONObject(new JSONTokener(jsonBody.getString("validationSchema"))); - schema = SchemaLoader.load(rawSchema); - } catch (JSONException e) { - return new SchemaInfo( - ORIGIN, - schema, - new ExternalServiceException(baseUrl.toString(), "No valid schema found resolving PID " + maybeTypePid) - ); - } - return new SchemaInfo(ORIGIN, schema, null); - } else if (statusCode == 404) { - return new SchemaInfo( - ORIGIN, - null, - new TypeNotFoundException(maybeTypePid) - ); - } else { + return this.http.get().uri(uriBuilder -> uriBuilder.pathSegment(maybeTypePid).build()) + .exchange((request, response) -> { + HttpStatusCode status = response.getStatusCode(); + if (status.is2xxSuccessful()) { + Schema schema = null; + try (InputStream inputStream = response.getBody()) { + JSONObject jsonBody = new JSONObject(new JSONTokener(inputStream)); + JSONObject rawSchema = new JSONObject(new JSONTokener(jsonBody.getString("validationSchema"))); + schema = SchemaLoader.load(rawSchema); + } catch (JSONException e) { return new SchemaInfo( ORIGIN, - null, - new ExternalServiceException( - this.baseUrl.toString(), - "Error generating schema: %s - %s".formatted(statusCode, response.getStatusLine().toString())) + schema, + new ExternalServiceException(baseUrl.toString(), "No valid schema found resolving PID " + maybeTypePid) ); } + return new SchemaInfo(ORIGIN, schema, null); + } else if (status.value() == 404) { + return new SchemaInfo( + ORIGIN, + null, + new TypeNotFoundException(maybeTypePid) + ); + } else { + return new SchemaInfo( + ORIGIN, + null, + new ExternalServiceException( + this.baseUrl.toString(), + "Error generating schema: %s - %s".formatted(status.value(), status.toString())) + ); } - ); - } catch (IOException e) { - return new SchemaInfo( - ORIGIN, - null, - new ExternalServiceException(baseUrl.toString(), "Error communicating with service.", e) - ); - } + }); } } From dc7fa02bb07c91319e703c68b4c1645f346398dd Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Mon, 13 Jan 2025 17:35:48 +0100 Subject: [PATCH 030/108] fix(CI): use temurin openJDK instead of zulu openJDK. We use temurin in our docker containers anyway, and had good experience with it so far. Also, zulu hat strange behaviour with async executors and has some notes in the setup-java github action documentation, which sounds unusual. --- .github/workflows/gradle.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 13430c4a..aea104db 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -18,6 +18,7 @@ on: env: COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} + JDK_DISTRO: 'temurin' jobs: build: @@ -34,7 +35,7 @@ jobs: uses: actions/setup-java@v4 with: java-version: ${{ matrix.jdk }} - distribution: 'zulu' # =openJDK + distribution: ${{ env.JDK_DISTRO }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - name: Build and Test with Gradle From bc2bb028fd2e9e8b51985e8dd8fe55b5297ff013 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Mon, 13 Jan 2025 17:49:26 +0100 Subject: [PATCH 031/108] fix: throw correct errors on pid creation --- .../impl/EmbeddedStrictValidatorStrategy.java | 4 ++++ .../kit/datamanager/pit/typeregistry/impl/TypeApi.java | 4 ++-- .../pit/web/impl/TypingRESTResourceImpl.java | 10 +++++----- src/test/resources/test/application-test.properties | 1 + 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java index e8128a8a..0326841b 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java @@ -83,9 +83,13 @@ public void validate(PIDRecord pidRecord) try { + LOG.trace("Processing all attributes in the record {}.", pidRecord.getPid()); CompletableFuture.allOf(attributeInfoFutures.toArray(new CompletableFuture[0])).join(); + LOG.trace("Finished processing all attributes in the record {}.", pidRecord.getPid()); } catch (CompletionException e) { + LOG.trace("Exception occurred during validation of record {}. Unpack Exception, if required.", pidRecord.getPid(), e); unpackAsyncExceptions(e); + LOG.trace("Exception was not unpacked. Rethrowing.", e); throw new ExternalServiceException(this.typeRegistry.getRegistryIdentifier()); } catch (CancellationException e) { unpackAsyncExceptions(e); diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java index 5f4e6cef..71aa1203 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java @@ -78,10 +78,10 @@ public TypeApi(ApplicationProperties properties, SchemaSetGenerator schemaSetGen .refreshAfterWrite(Duration.ofMinutes(expireAfterWrite / 2)) .expireAfterWrite(expireAfterWrite, TimeUnit.MINUTES) .removalListener((key, value, cause) -> - LOG.trace("Removing profile {} from profile cache. Cause: {}", key, cause) + LOG.trace("Removing profile {} from profile cache. Cause: {}", key, cause) ) .buildAsync(attributePid -> { - LOG.trace("Loading profile {} to cache.", attributePid); + LOG.trace("Loading attribute {} to cache.", attributePid); return this.queryAttribute(attributePid); }); } diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index db510c17..8bc6df96 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -8,11 +8,9 @@ import java.util.Optional; import java.util.stream.Stream; -import edu.kit.datamanager.pit.common.PidAlreadyExistsException; -import edu.kit.datamanager.pit.common.PidNotFoundException; +import edu.kit.datamanager.pit.common.*; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.configuration.PidGenerationProperties; -import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.domain.PIDRecord; import edu.kit.datamanager.pit.elasticsearch.PidRecordElasticRepository; import edu.kit.datamanager.pit.elasticsearch.PidRecordElasticWrapper; @@ -135,7 +133,8 @@ private void setPid(PIDRecord pidRecord) throws IOException { if (allowsCustomPids && hasCustomPid) { // in this only case, we do not have to generate a PID // but we have to check if the PID is already registered and return an error if so - String prefix = this.typingService.getPrefix().orElseThrow(() -> new IOException("No prefix configured.")); + String prefix = this.typingService.getPrefix() + .orElseThrow(() -> new InvalidConfigException("No prefix configured.")); String maybeSuffix = pidRecord.getPid(); String pid = PidSuffix.asPrefixedChecked(maybeSuffix, prefix); boolean isRegisteredPid = this.typingService.isPidRegistered(pid); @@ -153,7 +152,8 @@ private void setPid(PIDRecord pidRecord) throws IOException { .filter(suffix -> !this.typingService.isPidRegistered(suffix)) .stream() // back to normal java streams .findFirst(); // as the stream is infinite, we should always find a prefix. - PidSuffix suffix = maybeSuffix.orElseThrow(() -> new IOException("Could not generate PID suffix.")); + PidSuffix suffix = maybeSuffix + .orElseThrow(() -> new ExternalServiceException("Could not generate PID suffix which did not exist yet.")); pidRecord.setPid(suffix.get()); } } diff --git a/src/test/resources/test/application-test.properties b/src/test/resources/test/application-test.properties index 68824c34..7367d255 100644 --- a/src/test/resources/test/application-test.properties +++ b/src/test/resources/test/application-test.properties @@ -39,6 +39,7 @@ management.endpoints.web.exposure.include: * #logging.level.root: ERROR #logging.level.edu.kit.datamanager.doip:TRACE logging.level.edu.kit: DEBUG +#logging.level.edu.kit.datamanager.pit: TRACE #logging.level.org.springframework.transaction: TRACE logging.level.org.springframework: WARN logging.level.org.springframework.amqp: WARN From d1144f38114ada29008e2f6c936aa45792a319bd Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Mon, 13 Jan 2025 17:50:13 +0100 Subject: [PATCH 032/108] cleanup: spelling and outdated comments --- src/main/java/edu/kit/datamanager/pit/Application.java | 4 ---- .../kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/Application.java b/src/main/java/edu/kit/datamanager/pit/Application.java index 78ad39a8..29c0ea44 100644 --- a/src/main/java/edu/kit/datamanager/pit/Application.java +++ b/src/main/java/edu/kit/datamanager/pit/Application.java @@ -60,10 +60,6 @@ import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; import org.springframework.scheduling.annotation.EnableScheduling; -/** - * - * @author jejkal - */ @SpringBootApplication @EnableScheduling @EntityScan({ "edu.kit.datamanager" }) diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index 8bc6df96..982dd839 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -110,7 +110,7 @@ public ResponseEntity createPID( } PidRecordMessage message = PidRecordMessage.creation( pid, - "", // TODO parameter is depricated and will be removed soon. + "", // TODO parameter is deprecated and will be removed soon. AuthenticationHelper.getPrincipal(), ControllerUtils.getLocalHostname()); try { @@ -148,7 +148,7 @@ private void setPid(PIDRecord pidRecord) throws IOException { Stream suffixStream = suffixGenerator.infiniteStream(); Optional maybeSuffix = Streams.failableStream(suffixStream) - // With failible streams, we can throw exceptions. + // With failable streams, we can throw exceptions. .filter(suffix -> !this.typingService.isPidRegistered(suffix)) .stream() // back to normal java streams .findFirst(); // as the stream is infinite, we should always find a prefix. From 70ee315bfc098bcd0b5c7bf26da24af2565a2cee Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Mon, 13 Jan 2025 17:56:27 +0100 Subject: [PATCH 033/108] cleanup: avoid log spamming in the large record tests. --- .../pit/web/ExplicitValidationParametersTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java index f70f86c3..ce15c4d0 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java @@ -154,7 +154,7 @@ void testExtensiveRecordDryRun() throws Exception { .content(rJson) .accept(MediaType.ALL) ) - .andDo(MockMvcResultHandlers.print()) + //.andDo(MockMvcResultHandlers.print()) // output is massive due to the large record .andExpect(MockMvcResultMatchers.status().isOk()); // instead of created (201) // no PIDs are stored with dryrun @@ -181,9 +181,9 @@ void testExtensiveRecordWithoutDryRun() throws Exception { .content(rJson) .accept(MediaType.ALL) ) - .andDo(MockMvcResultHandlers.print()) + //.andDo(MockMvcResultHandlers.print()) // output is massive due to the large record .andExpect(MockMvcResultMatchers.status().isCreated()); // instead of created (201) - + // dryrun was false, so there should be a new PID known assertEquals(1, this.knownPidsDao.count()); } From ae2625abf6652d27f743f0b04d36d7d14c88af7f Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Tue, 14 Jan 2025 09:53:51 +0100 Subject: [PATCH 034/108] refactoring --- .../pit/web/impl/TypingRESTResourceImpl.java | 101 ++++++++++++------ 1 file changed, 66 insertions(+), 35 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index 524e2195..3c1737f4 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Karlsruhe Institute of Technology. + * Copyright (c) 2024-2025 Karlsruhe Institute of Technology. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -103,41 +103,10 @@ public ResponseEntity> createPIDs( String prefix = this.typingService.getPrefix().orElseThrow(() -> new IOException("No prefix configured.")); // Generate a map between temporary (user-defined) PIDs and final PIDs (generated) - Map pidMappings = new HashMap<>(); - for (PIDRecord pidRecord : rec) { - String internalPID = pidRecord.getPid(); - if (!internalPID.isBlank() && pidMappings.containsKey(internalPID)) { - // This internal PID was already used by some other record in the same request. - throw new RecordValidationException(pidRecord, "The PID " + internalPID + " was used for multiple records in the same request."); - } - - pidRecord.setPid(""); - if (dryrun) { - pidRecord.setPid("dryrun_" + pidMappings.size()); - } else { - setPid(pidRecord); - } - pidMappings.put(internalPID, pidRecord.getPid()); - } + Map pidMappings = generatePIDMapping(rec, dryrun); - List validatedRecords = new ArrayList<>(); - for (PIDRecord pidRecord : rec) { - - // use this map to replace all temporary PIDs in the record values with their corresponding real PIDs - pidRecord.getEntries().values().stream() - .flatMap(List::stream) - .filter(entry -> entry.getValue() != null) - .filter(entry -> pidMappings.containsKey(entry.getValue())) - .peek(entry -> LOG.debug("Found reference. Replacing {} with {}.", entry.getValue(), prefix + pidMappings.get(entry.getValue()))) - .forEach(entry -> entry.setValue(prefix + pidMappings.get(entry.getValue()))); - - // validate the record - this.typingService.validate(pidRecord); - - // store the record - validatedRecords.add(pidRecord); - LOG.debug("Record {} is valid.", pidRecord); - } + // Apply the mappings to the records and validate them + List validatedRecords = applyMappingsToRecordsAndValidate(rec, pidMappings, prefix); if (dryrun) { LOG.info("Dryrun finished. Returning validated records for {} records.", validatedRecords.size()); @@ -176,6 +145,68 @@ public ResponseEntity> createPIDs( return ResponseEntity.status(HttpStatus.CREATED).body(validatedRecords); } + /** + * This method generates a mapping between user-provided "fantasy" PIDs and real PIDs. + * + * @param rec the list of records produced by the user + * @param dryrun whether the operation is a dryrun or not + * @return a map between the user-provided PIDs (key) and the real PIDs (values) + * @throws IOException if the prefix is not configured + * @throws RecordValidationException if the same internal PID is used for multiple records + * @throws ExternalServiceException if the PID generation fails + */ + private Map generatePIDMapping(List rec, boolean dryrun) throws IOException, RecordValidationException, ExternalServiceException { + Map pidMappings = new HashMap<>(); + for (PIDRecord pidRecord : rec) { + String internalPID = pidRecord.getPid(); // the internal PID is the one given by the user + if (!internalPID.isBlank() && pidMappings.containsKey(internalPID)) { // check if the internal PID was already used + // This internal PID was already used by some other record in the same request. + throw new RecordValidationException(pidRecord, "The PID " + internalPID + " was used for multiple records in the same request."); + } + + pidRecord.setPid(""); // clear the PID field in the record + if (dryrun) { // if it is a dryrun, we set the PID to a temporary value + pidRecord.setPid("dryrun_" + pidMappings.size()); + } else { + setPid(pidRecord); // otherwise, we generate a real PID + } + pidMappings.put(internalPID, pidRecord.getPid()); // store the mapping between the internal and real PID + } + return pidMappings; + } + + /** + * This method applies the mappings between temporary PIDs and real PIDs to the records and validates them. + * + * @param rec the list of records produced by the user + * @param pidMappings the map between the user-provided PIDs (key) and the real PIDs (values) + * @param prefix the prefix to be used for the real PIDs + * @return the list of validated records + * @throws RecordValidationException as a possible validation outcome + * @throws ExternalServiceException as a possible validation outcome + */ + private List applyMappingsToRecordsAndValidate(List rec, Map pidMappings, String prefix) throws RecordValidationException, ExternalServiceException { + List validatedRecords = new ArrayList<>(); + for (PIDRecord pidRecord : rec) { + + // use this map to replace all temporary PIDs in the record values with their corresponding real PIDs + pidRecord.getEntries().values().stream() // get all values of the record + .flatMap(List::stream) // flatten the list of values + .filter(entry -> entry.getValue() != null) // Filter out null values + .filter(entry -> pidMappings.containsKey(entry.getValue())) // replace only if the value (aka. "fantasy PID") is a key in the map + .peek(entry -> LOG.debug("Found reference. Replacing {} with {}.", entry.getValue(), prefix + pidMappings.get(entry.getValue()))) // log the replacement + .forEach(entry -> entry.setValue(prefix + pidMappings.get(entry.getValue()))); // replace the value with the real PID according to the map + + // validate the record + this.typingService.validate(pidRecord); + + // store the record + validatedRecords.add(pidRecord); + LOG.debug("Record {} is valid.", pidRecord); + } + return validatedRecords; + } + @Override public ResponseEntity createPID( PIDRecord pidRecord, From 3ea89317cdfe0d0d5c138471ce010681ff133d81 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 14 Jan 2025 18:23:55 +0100 Subject: [PATCH 035/108] fix(test): improve output and adjust test to new profile behavior --- .../pit/common/TypeNotFoundException.java | 2 +- .../impl/EmbeddedStrictValidatorStrategy.java | 14 +++++++------- .../web/ExplicitValidationParametersTest.java | 16 ++++++++-------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/common/TypeNotFoundException.java b/src/main/java/edu/kit/datamanager/pit/common/TypeNotFoundException.java index bf78369a..7d87b3b7 100644 --- a/src/main/java/edu/kit/datamanager/pit/common/TypeNotFoundException.java +++ b/src/main/java/edu/kit/datamanager/pit/common/TypeNotFoundException.java @@ -13,6 +13,6 @@ public class TypeNotFoundException extends ResponseStatusException { private static final HttpStatus HTTP_STATUS = HttpStatus.NOT_FOUND; public TypeNotFoundException(String pid) { - super(HTTP_STATUS, "The given PID " + pid + " is not a type in the configured registry."); + super(HTTP_STATUS, "The given PID \"" + pid + "\" is not a type in the configured registry."); } } diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java index 0326841b..4e96e3ca 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java @@ -88,11 +88,11 @@ public void validate(PIDRecord pidRecord) LOG.trace("Finished processing all attributes in the record {}.", pidRecord.getPid()); } catch (CompletionException e) { LOG.trace("Exception occurred during validation of record {}. Unpack Exception, if required.", pidRecord.getPid(), e); - unpackAsyncExceptions(e); + unpackAsyncExceptions(pidRecord, e); LOG.trace("Exception was not unpacked. Rethrowing.", e); throw new ExternalServiceException(this.typeRegistry.getRegistryIdentifier()); } catch (CancellationException e) { - unpackAsyncExceptions(e); + unpackAsyncExceptions(pidRecord, e); throw new RecordValidationException( pidRecord, String.format("Validation task was cancelled for %s. Please report.", pidRecord.getPid())); @@ -105,11 +105,11 @@ public void validate(PIDRecord pidRecord) * Usually used to avoid exposing exceptions related to futures. * @param e the exception to unwrap. */ - private static void unpackAsyncExceptions(Throwable e) { - unpackAsyncExceptions(e, 0); + private static void unpackAsyncExceptions(PIDRecord pidRecord, Throwable e) { + unpackAsyncExceptions(pidRecord, e, 0); } - private static void unpackAsyncExceptions(Throwable e, int level) { + private static void unpackAsyncExceptions(PIDRecord pidRecord, Throwable e, int level) { Throwable cause = e.getCause(); final int MAX_LEVEL = 10; if (level > MAX_LEVEL || cause == null) { @@ -119,10 +119,10 @@ private static void unpackAsyncExceptions(Throwable e, int level) { throw rve; } else if (cause instanceof TypeNotFoundException tnf) { throw new RecordValidationException( - new PIDRecord(), + pidRecord, "Type not found: %s".formatted(tnf.getMessage())); } else { - unpackAsyncExceptions(cause, level + 1); + unpackAsyncExceptions(pidRecord, cause, level + 1); } } } diff --git a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java index ce15c4d0..a27713a0 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java @@ -246,20 +246,20 @@ void testResolvingValidRecordWithValidation() throws Exception { @Test @DisplayName("Resolve a PID known to be invalid, with explicit validation.") - void testResolvingValidRecordWithValidationFail() throws Exception { + void testResolvingInvalidRecordWithValidationFail() throws Exception { + // We'll reuse the extensive record here, and validate it. + // To do so, we resolve the PID, set validate to true, and expect a validation error. + // This error must occur, as all attributes are made up. These PIDs are not registered + // and were generated using this.pidGenerator. + // note: this test disables validation... this.testExtensiveRecordWithoutDryRun(); assertEquals(1, knownPidsDao.count()); String validPid = knownPidsDao.findAll().getFirst().getPid(); - - PIDRecord r = inMemory.queryPid(validPid); - r.addEntry("21.T11148/076759916209e5d62bd5", "", "21.T11148/b9b76f887845e32d29f7"); - r.addEntry("something wrong", "", "someVeryUniqueValue"); - inMemory.updatePid(r); // ... so we need to re-enable validation here: this.typingService.setValidationStrategy( this.appProps.defaultValidationStrategy(typeRegistry)); - + // Now, we can resolve and validate: MvcResult result = this.mockMvc .perform( get("/api/v1/pit/pid/" + validPid) @@ -268,7 +268,7 @@ void testResolvingValidRecordWithValidationFail() throws Exception { ) .andDo(MockMvcResultHandlers.print()) .andExpect(MockMvcResultMatchers.status().isBadRequest()) - .andExpect(MockMvcResultMatchers.jsonPath("$.detail", Matchers.containsString("Missing mandatory types: ["))) + .andExpect(MockMvcResultMatchers.jsonPath("$.detail", Matchers.containsString("Type not found"))) .andReturn(); assertFalse(result.getResponse().getContentAsString().isEmpty()); } From b12a4ddd798cb6093b35830682cbfd7671856ce6 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 14 Jan 2025 19:22:29 +0100 Subject: [PATCH 036/108] fix(test): fix invalid values before validation of real record --- .../pit/web/RestWithHandleProtocolTest.java | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java index 08a7c6fb..2891b068 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java @@ -7,6 +7,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockServletContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; @@ -89,20 +90,26 @@ void resolveSomething() throws Exception { } @Test - void testUpdateWithPidGiven() throws Exception { - // FIXME this test is currently not working as there is for some attributes no schema available in dtr-test, - // and the type-api does currently not work. - String etag = this.mockMvc.perform( - get("/api/v1/pit/pid/21.11152/474a4b1c-de93-4d4a-b33d-1d32d63baf4b?validation=false") + void testDryrunUpdateWithPidGiven() throws Exception { + String url = "/api/v1/pit/pid/21.11152/474a4b1c-de93-4d4a-b33d-1d32d63baf4b"; + MockHttpServletResponse response = this.mockMvc.perform( + get(url).param("validation", "false") ) .andDo(MockMvcResultHandlers.print()) .andExpect(MockMvcResultMatchers.status().is2xxSuccessful()) .andReturn() - .getResponse() - .getHeader("ETag"); + .getResponse(); + String etag = response.getHeader("ETag"); + PIDRecord record = mapper.readValue(response.getContentAsString(), PIDRecord.class); + // fix record, it is actually invalid... + record.removeAllValuesOf("URL"); + String licenseUrl = "21.T11148/2f314c8fe5fb6a0063a8"; + record.removeAllValuesOf(licenseUrl); + record.addEntry(licenseUrl, "{ \"licenseURL\": \"https://cdla.dev/permissive-2-0/\" }"); this.mockMvc.perform( - put("/api/v1/pit/pid/21.11152/474a4b1c-de93-4d4a-b33d-1d32d63baf4b?dryrun=true") - .content("{ \"pid\": \"21.11152/474a4b1c-de93-4d4a-b33d-1d32d63baf4b\", \"entries\": { \"21.T11148/076759916209e5d62bd5\": [ { \"key\": \"21.T11148/076759916209e5d62bd5\", \"name\": \"kernelInformationProfile\", \"value\": \"21.T11148/b9b76f887845e32d29f7\" } ], \"21.T11148/397d831aa3a9d18eb52c\": [ { \"key\": \"21.T11148/397d831aa3a9d18eb52c\", \"name\": \"dateModified\", \"value\": \"2024-10-14T07:16:46+00:00\" } ], \"21.T11148/82e2503c49209e987740\": [ { \"key\": \"21.T11148/82e2503c49209e987740\", \"name\": \"checksum\", \"value\": \"{ \\\"sha256sum\\\": \\\"a92ad3bd2b0856b70d3f98cb2fa21964ea7f91218c46e327b65a0937c50a885c\\\" }\" } ], \"21.T11148/aafd5fb4c7222e2d950a\": [ { \"key\": \"21.T11148/aafd5fb4c7222e2d950a\", \"name\": \"dateCreated\", \"value\": \"2024-10-14T07:16:46+00:00\" } ], \"21.T11148/b8457812905b83046284\": [ { \"key\": \"21.T11148/b8457812905b83046284\", \"name\": \"digitalObjectLocation\", \"value\": \"https://paint-database.org/WRI1030197/WRI1030197-catalog-stac.json\" } ], \"21.T11148/1a73af9e7ae00182733b\": [ { \"key\": \"21.T11148/1a73af9e7ae00182733b\", \"name\": \"contact\", \"value\": \"https://orcid.org/0009-0007-0235-4995\" }, { \"key\": \"21.T11148/1a73af9e7ae00182733b\", \"name\": \"contact\", \"value\": \"https://orcid.org/0000-0002-2233-1041\" }, { \"key\": \"21.T11148/1a73af9e7ae00182733b\", \"name\": \"contact\", \"value\": \"https://orcid.org/0000-0001-9648-4385\" }, { \"key\": \"21.T11148/1a73af9e7ae00182733b\", \"name\": \"contact\", \"value\": \"https://orcid.org/0000-0002-9197-1739\" }, { \"key\": \"21.T11148/1a73af9e7ae00182733b\", \"name\": \"contact\", \"value\": \"https://orcid.org/0000-0002-4705-6285\" } ], \"21.T11148/2f314c8fe5fb6a0063a8\": [ { \"key\": \"21.T11148/2f314c8fe5fb6a0063a8\", \"name\": \"licenseURL\", \"value\": \"https://cdla.dev/permissive-2-0/\" } ], \"21.T11148/1c699a5d1b4ad3ba4956\": [ { \"key\": \"21.T11148/1c699a5d1b4ad3ba4956\", \"name\": \"digitalResourceType\", \"value\": \"application/json\" } ] }}") + put(url) + .param("dryrun", "true") + .content(mapper.writeValueAsString(record)) .contentType(ContentType.APPLICATION_JSON.getMimeType()) .header("If-Match", etag) ) From 8a9609bc63c182ce3053f1ab132443414ade9039 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 14 Jan 2025 19:23:26 +0100 Subject: [PATCH 037/108] cleanup: remove useless newline in exception message --- .../kit/datamanager/pit/common/RecordValidationException.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java b/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java index ab63aab0..b5edbdc7 100644 --- a/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java +++ b/src/main/java/edu/kit/datamanager/pit/common/RecordValidationException.java @@ -24,12 +24,12 @@ public RecordValidationException(PIDRecord pidRecord) { } public RecordValidationException(PIDRecord pidRecord, String reason) { - super(HTTP_STATUS, VALIDATION_OF_RECORD + pidRecord.getPid() + " failed. Reason:\n" + reason); + super(HTTP_STATUS, VALIDATION_OF_RECORD + pidRecord.getPid() + " failed. Reason: " + reason); this.pidRecord = pidRecord; } public RecordValidationException(PIDRecord pidRecord, String reason, Exception e) { - super(HTTP_STATUS, VALIDATION_OF_RECORD + pidRecord.getPid() + " failed. Reason:\n" + reason, e); + super(HTTP_STATUS, VALIDATION_OF_RECORD + pidRecord.getPid() + " failed. Reason: " + reason, e); this.pidRecord = pidRecord; } From 90539c22cc32fabef7847039516e70b4501d0e13 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 14 Jan 2025 19:31:00 +0100 Subject: [PATCH 038/108] cleanup: make executor types easily changeable in one line --- src/main/java/edu/kit/datamanager/pit/Application.java | 6 ++++++ .../edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java | 4 ++-- .../pit/typeregistry/schema/SchemaSetGenerator.java | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/Application.java b/src/main/java/edu/kit/datamanager/pit/Application.java index 29c0ea44..5a252b80 100644 --- a/src/main/java/edu/kit/datamanager/pit/Application.java +++ b/src/main/java/edu/kit/datamanager/pit/Application.java @@ -38,6 +38,8 @@ import java.io.IOException; import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.stream.Stream; import org.apache.http.client.HttpClient; @@ -89,6 +91,10 @@ public Logger logger(InjectionPoint injectionPoint) { return LoggerFactory.getLogger(targetClass.getCanonicalName()); } + public static ExecutorService newExecutor() { + return Executors.newVirtualThreadPerTaskExecutor(); + } + @Bean public SchemaSetGenerator schemaSetGenerator(ApplicationProperties props) { return new SchemaSetGenerator(props); diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java index 71aa1203..038c8a5d 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java @@ -61,7 +61,7 @@ public TypeApi(ApplicationProperties properties, SchemaSetGenerator schemaSetGen this.profileCache = Caffeine.newBuilder() .maximumSize(maximumSize) - .executor(Executors.newVirtualThreadPerTaskExecutor()) + .executor(Application.newExecutor()) .refreshAfterWrite(Duration.ofMinutes(expireAfterWrite / 2)) .expireAfterWrite(expireAfterWrite, TimeUnit.MINUTES) .removalListener((key, value, cause) -> @@ -74,7 +74,7 @@ public TypeApi(ApplicationProperties properties, SchemaSetGenerator schemaSetGen this.attributeCache = Caffeine.newBuilder() .maximumSize(maximumSize) - .executor(Executors.newVirtualThreadPerTaskExecutor()) + .executor(Application.newExecutor()) .refreshAfterWrite(Duration.ofMinutes(expireAfterWrite / 2)) .expireAfterWrite(expireAfterWrite, TimeUnit.MINUTES) .removalListener((key, value, cause) -> diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java index 25662e5a..11527b1e 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java @@ -24,7 +24,7 @@ public SchemaSetGenerator(ApplicationProperties props) { CACHE = Caffeine.newBuilder() .maximumSize(props.getMaximumSize()) - .executor(Executors.newVirtualThreadPerTaskExecutor()) + .executor(Application.newExecutor()) .refreshAfterWrite(Duration.ofMinutes(props.getExpireAfterWrite() / 2)) .expireAfterWrite(props.getExpireAfterWrite(), TimeUnit.MINUTES) .buildAsync(attributePid -> GENERATORS.stream() From 643e2be770b290e9865fc03f3d681bc8d3ced2af Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 15 Jan 2025 16:37:37 +0100 Subject: [PATCH 039/108] cleanup: rewrite unpacking of exceptions without recursion --- .../impl/EmbeddedStrictValidatorStrategy.java | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java index 4e96e3ca..7d7d1e3e 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java @@ -106,23 +106,18 @@ public void validate(PIDRecord pidRecord) * @param e the exception to unwrap. */ private static void unpackAsyncExceptions(PIDRecord pidRecord, Throwable e) { - unpackAsyncExceptions(pidRecord, e, 0); - } - - private static void unpackAsyncExceptions(PIDRecord pidRecord, Throwable e, int level) { - Throwable cause = e.getCause(); final int MAX_LEVEL = 10; - if (level > MAX_LEVEL || cause == null) { - return; - } - if (cause instanceof RecordValidationException rve) { - throw rve; - } else if (cause instanceof TypeNotFoundException tnf) { - throw new RecordValidationException( - pidRecord, - "Type not found: %s".formatted(tnf.getMessage())); - } else { - unpackAsyncExceptions(pidRecord, cause, level + 1); + Throwable cause = e; + + for (int level = 0; level <= MAX_LEVEL && cause != null; level++) { + cause = cause.getCause(); + if (cause instanceof RecordValidationException rve) { + throw rve; + } else if (cause instanceof TypeNotFoundException tnf) { + throw new RecordValidationException( + pidRecord, + "Type not found: %s".formatted(tnf.getMessage())); + } } } } From d2b558214f7efaef72f58675ec6c81341544191c Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Fri, 17 Jan 2025 11:06:36 +0100 Subject: [PATCH 040/108] cleanup: better names for some config properties --- .../configuration/ApplicationProperties.java | 32 +++++++++---------- .../impl/EmbeddedStrictValidatorStrategy.java | 2 +- .../pit/typeregistry/impl/TypeApi.java | 5 ++- .../schema/SchemaSetGenerator.java | 7 ++-- .../pit/typeregistry/impl/TypeApiTest.java | 4 +-- .../schema/SchemaSetGeneratorTest.java | 7 ++-- 6 files changed, 26 insertions(+), 31 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java b/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java index c714b959..c4adeab6 100644 --- a/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java +++ b/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java @@ -51,7 +51,7 @@ @Validated public class ApplicationProperties extends GenericApplicationProperties { - private static Set KNOWN_PROFILE_KEYS = Set.of( + private static final Set KNOWN_PROFILE_KEYS = Set.of( "21.T11148/076759916209e5d62bd5", "21.T11969/bcc54a2a9ab5bf2a8f2c" ); @@ -112,24 +112,24 @@ public boolean storesResolved() { private URL typeRegistryUri; @Value("${pit.typeregistry.cache.maxEntries:1000}") - private int maximumSize; + private int cacheMaxEntries; @Value("${pit.typeregistry.cache.lifetimeMinutes:10}") - private long expireAfterWrite; + private long cacheExpireAfterWriteLifetime; @Value("${pit.validation.profileKey:21.T11148/076759916209e5d62bd5}") @Deprecated(forRemoval = true /*In Typed PID Maker 3.0.0*/) private String profileKey; - @Getter - @Setter - @Value("${pit.validation.alwaysAllowAdditionalAttributes:true}") - private boolean alwaysAllowAdditionalAttributes = true; - @Value("#{${pit.validation.profileKeys:{}}}") @NotNull protected List profileKeys = List.of(); + @Getter + @Setter + @Value("${pit.validation.alwaysAllowAdditionalAttributes:true}") + private boolean validationAlwaysAllowAdditionalAttributes = true; + public @NotNull Set getProfileKeys() { Set allProfileKeys = new java.util.HashSet<>(Set.copyOf(KNOWN_PROFILE_KEYS)); allProfileKeys.addAll(profileKeys); @@ -183,20 +183,20 @@ public void setValidationStrategy(ValidationStrategy strategy) { this.validationStrategy = strategy; } - public int getMaximumSize() { - return maximumSize; + public int getCacheMaxEntries() { + return cacheMaxEntries; } - public void setMaximumSize(int maximumSize) { - this.maximumSize = maximumSize; + public void setCacheMaxEntries(int cacheMaxEntries) { + this.cacheMaxEntries = cacheMaxEntries; } - public long getExpireAfterWrite() { - return expireAfterWrite; + public long getCacheExpireAfterWriteLifetime() { + return cacheExpireAfterWriteLifetime; } - public void setExpireAfterWrite(long expireAfterWrite) { - this.expireAfterWrite = expireAfterWrite; + public void setCacheExpireAfterWriteLifetime(long cacheExpireAfterWriteLifetime) { + this.cacheExpireAfterWriteLifetime = cacheExpireAfterWriteLifetime; } public StorageStrategy getStorageStrategy() { diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java index 7d7d1e3e..9d08d768 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java @@ -38,7 +38,7 @@ public EmbeddedStrictValidatorStrategy( ) { this.typeRegistry = typeRegistry; this.profileKeys = config.getProfileKeys(); - this.alwaysAcceptAdditionalAttributes = config.isAlwaysAllowAdditionalAttributes(); + this.alwaysAcceptAdditionalAttributes = config.isValidationAlwaysAllowAdditionalAttributes(); } @Override diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java index 038c8a5d..a7ab2bcf 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java @@ -29,7 +29,6 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.stream.StreamSupport; @@ -56,8 +55,8 @@ public TypeApi(ApplicationProperties properties, SchemaSetGenerator schemaSetGen this.http = RestClient.builder().baseUrl(baseUri).build(); // TODO better name caching properties (and consider extending them) - int maximumSize = properties.getMaximumSize(); - long expireAfterWrite = properties.getExpireAfterWrite(); + int maximumSize = properties.getCacheMaxEntries(); + long expireAfterWrite = properties.getCacheExpireAfterWriteLifetime(); this.profileCache = Caffeine.newBuilder() .maximumSize(maximumSize) diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java index 11527b1e..166fae87 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java @@ -8,7 +8,6 @@ import java.time.Duration; import java.util.Set; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -23,10 +22,10 @@ public SchemaSetGenerator(ApplicationProperties props) { ); CACHE = Caffeine.newBuilder() - .maximumSize(props.getMaximumSize()) + .maximumSize(props.getCacheMaxEntries()) .executor(Application.newExecutor()) - .refreshAfterWrite(Duration.ofMinutes(props.getExpireAfterWrite() / 2)) - .expireAfterWrite(props.getExpireAfterWrite(), TimeUnit.MINUTES) + .refreshAfterWrite(Duration.ofMinutes(props.getCacheExpireAfterWriteLifetime() / 2)) + .expireAfterWrite(props.getCacheExpireAfterWriteLifetime(), TimeUnit.MINUTES) .buildAsync(attributePid -> GENERATORS.stream() .map(schemaGenerator -> schemaGenerator.generateSchema(attributePid)) .collect(Collectors.toSet()) diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java index aafccb23..205c19af 100644 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java @@ -23,8 +23,8 @@ class TypeApiTest { TypeApiTest() throws MalformedURLException, URISyntaxException { ApplicationProperties props = new ApplicationProperties(); // set cache properties - props.setExpireAfterWrite(10); - props.setMaximumSize(1000); + props.setCacheExpireAfterWriteLifetime(10); + props.setCacheMaxEntries(1000); // set type registry props.setTypeRegistryUri(new URI("https://typeapi.lab.pidconsortium.net").toURL()); props.setHandleBaseUri(new URI("https://hdl.handle.net").toURL()); diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java index 0d9addbe..f29b1f85 100644 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java @@ -2,16 +2,13 @@ import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.typeregistry.AttributeInfo; -import org.everit.json.schema.Schema; import org.everit.json.schema.ValidationException; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.net.URI; import java.util.NoSuchElementException; -import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; @@ -23,8 +20,8 @@ class SchemaSetGeneratorTest { @BeforeAll static void setup() throws Exception { properties = new ApplicationProperties(); - properties.setExpireAfterWrite(10); - properties.setMaximumSize(1000); + properties.setCacheExpireAfterWriteLifetime(10); + properties.setCacheMaxEntries(1000); properties.setTypeRegistryUri(new URI("https://typeapi.lab.pidconsortium.net").toURL()); properties.setHandleBaseUri(new URI("https://hdl.handle.net").toURL()); generator = new SchemaSetGenerator(properties); From a19e2eaf41a703bfea78e4ef7d847c6f25639c71 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Fri, 17 Jan 2025 11:07:25 +0100 Subject: [PATCH 041/108] cleanup: use clearer name for additional attributes --- .../kit/datamanager/pit/typeregistry/RegisteredProfile.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java index d6c0f689..44710815 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java @@ -15,19 +15,19 @@ public record RegisteredProfile( ) { public void validateAttributes(PIDRecord pidRecord, boolean alwaysAllowAdditionalAttributes) { - Set additionalAttributes = pidRecord.getPropertyIdentifiers().stream() + Set attributesNotDefinedInProfile = pidRecord.getPropertyIdentifiers().stream() .filter(recordKey -> attributes.items().stream().anyMatch( profileAttribute -> Objects.equals(profileAttribute.pid(), recordKey))) .collect(Collectors.toSet()); boolean additionalAttributesForbidden = !this.allowAdditionalAttributes && !alwaysAllowAdditionalAttributes; - boolean violatesAdditionalAttributes = additionalAttributesForbidden && !additionalAttributes.isEmpty(); + boolean violatesAdditionalAttributes = additionalAttributesForbidden && !attributesNotDefinedInProfile.isEmpty(); if (violatesAdditionalAttributes) { throw new RecordValidationException( pidRecord, String.format("Attributes %s are not allowed in profile %s", - String.join(", ", additionalAttributes), + String.join(", ", attributesNotDefinedInProfile), this.pid) ); } From d9d507ce467fab5e85673dc66ab8cc3bb0f787f2 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Fri, 17 Jan 2025 11:38:03 +0100 Subject: [PATCH 042/108] docs: document config properties in application-default.properties --- config/application-default.properties | 33 ++++++++++++++++--- .../configuration/ApplicationProperties.java | 5 +++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/config/application-default.properties b/config/application-default.properties index c80106df..7744fb97 100644 --- a/config/application-default.properties +++ b/config/application-default.properties @@ -172,15 +172,38 @@ pit.pidsystem.handle-protocol.handleRedirectAttributes = {'21.T11148/b8457812905 ### Base URL for the DTR used. ### # Currently, we support the DTRs of GWDG/ePIC. pit.typeregistry.baseURI = https://typeapi.lab.pidconsortium.net -# If the attribute(s) keys/types in your PID records are not being recognized as such, please contact us. -# As a workaround, add them to this list: -pit.validation.profileKeys = {} +# If the attribute(s) keys/types in your PID records are not being +# recognized as such, please contact us. +# As a workaround, add them to this list. +# pit.validation.profileKeys = {} ### As this service is a RESTful serice without GUI, CSRF protection is not required. ### pit.security.enable-csrf: false ### You may define patterns here for services which are allowed for communication. (CORS) ### pit.security.allowedOriginPattern: http*://localhost:[*] +### Caching settings for validation ### +# The maximum number of entries in the cache. +# pit.typeregistry.cache.maxEntries:1000 +# +# The time in minutes after which Entries will expire, starting from the +# last update. +# pit.typeregistry.cache.lifetimeMinutes:10 + +# Profiles may disallow additional attributes in the PID records. This +# option may be used to override this behavior for this instance. +# If set to false, it will behave as the profiles describe. +# If set to true, additional attributes will always be allowed. +pit.validation.alwaysAllowAdditionalAttributes = true + +### DANGEROUS OPTIONS! Please read carefully! ######################################## +# This will disable validation. It is only meant for testing and rare cases +# where a DTR may not be available or an external validator is being +# used. +# +# pit.validation.strategy = debug-none +### DANGEROUS OPTIONS! Please read carefully! ######################################## + ####################################################### #################### PID GENERATOR #################### ####################################################### @@ -208,7 +231,7 @@ pit.pidgeneration.casing = lower # Default: 4 # pit.pidgeneration.num-chunks = 4 -### DANGEROUS OPTION! Please read carefully! ######################################## +### DANGEROUS OPTIONS! Please read carefully! ######################################## # Please keep this option as a last resort vor special use-cases # where you need total control about the PID suffix you want to create. # In addition to authentication, we recommend fully hide the Typed PID Maker behind @@ -219,7 +242,7 @@ pit.pidgeneration.casing = lower # => PID = "abc/def" (delimiter may depend on PID system) # # pit.pidgeneration.custom-client-pids-enabled = false -### DANGEROUS OPTION! Please read carefully! ######################################## +### DANGEROUS OPTIONS! Please read carefully! ######################################## ################################ ######## Database ############## diff --git a/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java b/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java index c4adeab6..438a40bd 100644 --- a/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java +++ b/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java @@ -51,6 +51,11 @@ @Validated public class ApplicationProperties extends GenericApplicationProperties { + /** + * Internal default set of types which indicate that, when used as a key + * of an attribute, that the value of the attribute must be a profile. + * Used for profile detection in records. + */ private static final Set KNOWN_PROFILE_KEYS = Set.of( "21.T11148/076759916209e5d62bd5", "21.T11969/bcc54a2a9ab5bf2a8f2c" From 8a9691dfa16551c7e4ade350e4a23511f0fd1967 Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Mon, 20 Jan 2025 15:09:33 +0100 Subject: [PATCH 043/108] added time debugging logs --- .../pit/web/impl/TypingRESTResourceImpl.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index 3c1737f4..2d492320 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -99,16 +99,23 @@ public ResponseEntity> createPIDs( HttpServletResponse response, UriComponentsBuilder uriBuilder ) throws IOException, RecordValidationException, ExternalServiceException { + Instant startTime = Instant.now(); LOG.info("Creating PIDs for {} records.", rec.size()); String prefix = this.typingService.getPrefix().orElseThrow(() -> new IOException("No prefix configured.")); // Generate a map between temporary (user-defined) PIDs and final PIDs (generated) Map pidMappings = generatePIDMapping(rec, dryrun); + Instant mappingTime = Instant.now(); // Apply the mappings to the records and validate them List validatedRecords = applyMappingsToRecordsAndValidate(rec, pidMappings, prefix); + Instant validationTime = Instant.now(); if (dryrun) { + // dryrun only does validation. Stop now and return as we would later on. + LOG.info("Time taken for dryrun: {} ms", ChronoUnit.MILLIS.between(startTime, validationTime)); + LOG.info("-- Time taken for mapping: {} ms", ChronoUnit.MILLIS.between(startTime, mappingTime)); + LOG.info("-- Time taken for validation: {} ms", ChronoUnit.MILLIS.between(mappingTime, validationTime)); LOG.info("Dryrun finished. Returning validated records for {} records.", validatedRecords.size()); return ResponseEntity.status(HttpStatus.OK).body(validatedRecords); } @@ -139,8 +146,13 @@ public ResponseEntity> createPIDs( // save the record to elastic this.saveToElastic(pidRecord); }); + Instant endTime = Instant.now(); // return the created records + LOG.info("Total time taken: {} ms", ChronoUnit.MILLIS.between(startTime, endTime)); + LOG.info("-- Time taken for mapping: {} ms", ChronoUnit.MILLIS.between(startTime, mappingTime)); + LOG.info("-- Time taken for validation: {} ms", ChronoUnit.MILLIS.between(mappingTime, validationTime)); + LOG.info("-- Time taken for registration: {} ms", ChronoUnit.MILLIS.between(validationTime, endTime)); LOG.info("Creation finished. Returning validated records for {} records.", validatedRecords.size()); return ResponseEntity.status(HttpStatus.CREATED).body(validatedRecords); } From c1fdc5dc1b300e408b18f05257bb24250b03c266 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 22 Jan 2025 00:36:47 +0100 Subject: [PATCH 044/108] feat: warn via logging if the cache size is unusual low. This is useful for deployment, but also to see in benchmarks if the parameter has been set properly. --- .../pit/configuration/ApplicationProperties.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java b/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java index 438a40bd..a9c422fa 100644 --- a/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java +++ b/src/main/java/edu/kit/datamanager/pit/configuration/ApplicationProperties.java @@ -29,6 +29,8 @@ import lombok.Getter; import lombok.Setter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -50,6 +52,7 @@ @Configuration @Validated public class ApplicationProperties extends GenericApplicationProperties { + private static final Logger LOG = LoggerFactory.getLogger(ApplicationProperties.class); /** * Internal default set of types which indicate that, when used as a key @@ -189,7 +192,10 @@ public void setValidationStrategy(ValidationStrategy strategy) { } public int getCacheMaxEntries() { - return cacheMaxEntries; + if (this.cacheMaxEntries <= 10) { + LOG.warn("Cache max entries is set to {} (low value)", this.cacheMaxEntries); + } + return this.cacheMaxEntries; } public void setCacheMaxEntries(int cacheMaxEntries) { From 0aada098bc8618762538a1988249dfdfc6904af7 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 22 Jan 2025 00:54:11 +0100 Subject: [PATCH 045/108] feat: implement dockerized benchmarks --- README.md | 5 +- docker/README.md | 30 +++++---- docker/test_docker.sh | 80 ++++++++++++++++------- docker/tests/benchmark-create_dryrun.hurl | 65 ++++++++++++++++++ 4 files changed, 141 insertions(+), 39 deletions(-) create mode 100644 docker/tests/benchmark-create_dryrun.hurl diff --git a/README.md b/README.md index 1c36208a..138f5b15 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,8 @@ This implies the following properties: - Building (with tests): `./gradlew clean build` - Building (with verbose test output) `./gradlew -Dprofile=verbose clean build` - Building (without tests): `./gradlew clean build -x test` -- Run docker integration tests: - - `./gradlew clean build` (by default, this will reuse the local build) - - `time bash ./docker/test_docker.sh` (runs test script) +- Run docker integration tests: `time bash ./docker/test_docker.sh` (will reuse the local build) +- Run dockerized validation benchmarks: `time bash ./docker/test_docker.sh benchmark` (will reuse the local build) - Doing a release: `./gradlew clean build release` - Will prompt you about version number to use and next version number - Will make a git tag which can later be used in a GitHub release diff --git a/docker/README.md b/docker/README.md index 4c1c9042..f3d993ea 100644 --- a/docker/README.md +++ b/docker/README.md @@ -2,25 +2,33 @@ There are two images: -1. `Dockerfile-build-in-image` will build the image in the container. The result is still a clean image as it uses multi-stage-builds to only include what it really needs to run. This is good for a container release. -2. `Dockerfile-reuse-local-build` will reuse the build on your local machine. This is good for local development and reusing CI artifacts. +- `Dockerfile-build-in-image` + - Will build the image in the container. The result is still a clean image as it uses multi-stage-builds to only include what it really needs to run. + - Best for making a container release. + - Used by CI to create an image for publication. +- `Dockerfile-reuse-local-build` + - Will reuse the build on your local machine. + - Good for local development and reusing CI artifacts. + - Used in `test_docker.sh` and build/test CI. -The CI will use the first Dockerfile to build and provide images. -## Testing +## Run all tests -The test script `docker_tests.sh` will: +For the recommended usage instructions, see the main readme file. The test script `docker_tests.sh` will: - build and run a container, named `typed-pid-maker-test` - - without parameters, it will use the `Dockerfile-reuse-local-build` definition. - - given an arbitrary argument (we recommend "release" or "build-in-image" for readability), it will use the `Dockerfile-build-in-image` definition. -- execute all tests in the `tests` subfolder +- if the script gets `benchmark` as its first parameter: + - execute benchmark tests repeatedly +- else (default): + - execute all tests in the `tests` subfolder - stop and delete the container -The idea behind these tests is to have basic tests from a very practical perspective (integration tests, component/service tests). The goal is to test the docker container, but also the standard configuration (application.properties) and the spring setup in general. Examples: +The idea behind the tests is to have basic tests from a very practical perspective (integration tests, component/service tests). The goal is to test the docker container, but also the standard configuration (application.properties) and the spring setup in general. Examples: -- test the creation of a PID: This makes sure that the request actually reaches the handler function. Such a test should exist for all endpoint with different security configurations. +- test the creation of a PID: This makes sure that the request actually reaches the handler function. Such a test should exist for all endpoints with different security configurations. - test if swagger page is reachable: This makes sure that the spring and openapi/swagger libraries work well together in the current version. - test if openAPI definition is reachable: same as above. -The goal is **not** to achieve full test coverage here. For this, we have unit tests and integration tests in `src/test`. \ No newline at end of file +The goal is **not** to achieve full test coverage here. For this, we have unit tests and integration tests in `src/test`. + +The benchmark mode is available to see the impact of new developments on the validation. \ No newline at end of file diff --git a/docker/test_docker.sh b/docker/test_docker.sh index 10f8f312..6eba6fd3 100644 --- a/docker/test_docker.sh +++ b/docker/test_docker.sh @@ -1,46 +1,76 @@ #!/usr/bin/env bash +# hurl is required +if ! command -v hurl &>/dev/null; then + echo "> hurl is required but it's not installed. Aborting." >&2 + exit 1 +fi + +if [ "$1" == "benchmark" ]; then + benchmark_mode=0 # 0 (true) for benchmarks + echo "> benchmark mode" +else + benchmark_mode=1 # 1 (false) for tests + echo "> test mode" +fi + # docker parameters tag=typed-pid-maker-test container=typid-test + # meta information for this script this=${BASH_SOURCE[0]} -echo "this script is at $this" +echo "> this script is at $this" docker_dir=$(dirname "$this") -echo "build docker image" +echo "> trigger local build" +"$docker_dir"/../gradlew build -x test || exit 1 + +echo "> build docker image, reusing local build" sleep .2 -# use "standalone" or "release" to build in the docker container -if [ "$1" ] -then - echo " > compiling in container: " - docker build --file $docker_dir/Dockerfile-build-in-image --tag $tag $docker_dir/.. || exit 1 -else - echo " > reusing local build: " - docker build --file $docker_dir/Dockerfile-reuse-local-build --tag $tag $docker_dir/.. || exit 1 -fi +docker build --file "$docker_dir/Dockerfile-reuse-local-build" \ + --tag "$tag" "$docker_dir/.." || exit 1 -echo -n "run container: " -docker run -p 8090:8090 --detach --name $container $tag +echo -n "> run container: " +docker run \ + --env pit.typeregistry.cache.maxEntries=0 \ + -p 8090:8090 \ + --detach \ + --name $container $tag + +echo "> Making sure the service is ready to accept requests" +hurl --retry=20 --retry-interval=5000 \ + --test "$docker_dir"/tests/create_resolve_exists.hurl || + exit 1 +echo "> Service is ready. Starting with actual tests." ##################################### -### tests ########################### +### run tests / benchmarks ################ ##################################### -hurl --retry=10 --retry-interval=10000 \ - --test "$docker_dir"/tests/*.hurl -failure=$? +echo "> benchmark mode: $benchmark_mode" +if [ "$benchmark_mode" -eq 0 ]; then + echo "> running benchmark" + hurl --retry=20 --retry-interval=5000 \ + --test --jobs 1 --repeat 1000 \ + "$docker_dir"/tests/benchmark-create_dryrun.hurl + failure=$? +else + echo "> running tests" + hurl --retry=20 --retry-interval=5000 \ + --test "$docker_dir"/tests/*.hurl + failure=$? +fi ##################################### -echo -n "stopping container ... " +echo -n "> stopping container ... " docker container stop $container -echo -n "removing container ... " +echo -n "> removing container ... " docker container rm $container -if [ $failure -eq 0 ] -then - echo "ALL TESTS SUCCESSFUL." - exit 0 +if [ $failure -eq 0 ]; then + echo "> ALL TESTS SUCCESSFUL." + exit 0 else - echo "TESTS FAILED (see output for details)" - exit 1 + echo "> TESTS FAILED (see output for details)" + exit 1 fi diff --git a/docker/tests/benchmark-create_dryrun.hurl b/docker/tests/benchmark-create_dryrun.hurl new file mode 100644 index 00000000..cb0b9643 --- /dev/null +++ b/docker/tests/benchmark-create_dryrun.hurl @@ -0,0 +1,65 @@ +# create/register a pid record +POST http://localhost:8090/api/v1/pit/pid/ +Content-Type: application/vnd.datamanager.pid.simple+json +Accept: application/vnd.datamanager.pid.simple+json +[QueryStringParams] +dryrun: true +{ + "record": [ + { + "key": "21.T11148/076759916209e5d62bd5", + "value": "21.T11148/b9b76f887845e32d29f7" + }, + { + "key": "21.T11148/1c699a5d1b4ad3ba4956", + "value": "21.T11148/ca9fd0b2414177b79ac2" + }, + { + "key": "21.T11148/a753134738da82809fc1", + "value": "21.T11148/a753134738da82809fc1" + }, + { + "key": "21.T11148/b8457812905b83046284", + "value": "https://hdl.handle.net/21.T11148/b8457812905b83046284" + }, + { + "key": "21.T11148/1a73af9e7ae00182733b", + "value": "https://orcid.org/0000-0001-6575-1022" + }, + { + "key": "21.T11148/aafd5fb4c7222e2d950a", + "value": "2020-10-21T00:00:00+02:00" + }, + { + "key": "21.T11969/a00985b98dac27bd32f8", + "value": "Book" + }, + { + "key": "21.T11148/2f314c8fe5fb6a0063a8", + "value": "{\"licenseURL\": \"https://www.gnu.org/licenses/agpl-3.0.en.html\"}" + }, + { + "key": "21.T11148/82e2503c49209e987740", + "value": "{\"md5sum\": \"2289159614f3e3b06fc436423c0dc398\"}" + }, + { + "key": "21.T11148/7fdada5846281ef5d461", + "value": "{\"locationPreview/Sample\": \"https://example.com/my/path/to/image.svg\"}" + }, + { + "key": "21.T11148/6ae999552a0d2dca14d6", + "value": "this-is-a-string" + }, + { + "key": "21.T11148/f3f0cbaa39fa9966b279", + "value": "{\"identifier\": \"this-is-a-string\"}" + }, + { + "key": "21.T11148/4fe7cde52629b61e3b82", + "value": "sandboxed/some-random-pid" + } + ] +} + +# on success, we get a 200 (no 201, because we're in dry-run mode) +HTTP 200 From c6fb821902ba04a3c2ec138639341ea6f7437958 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 22 Jan 2025 01:45:24 +0100 Subject: [PATCH 046/108] fix: make sure test script does not influence caches or else in preparation steps. --- docker/test_docker.sh | 4 +++- docker/tests/resolve-nonexisting.hurl | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 docker/tests/resolve-nonexisting.hurl diff --git a/docker/test_docker.sh b/docker/test_docker.sh index 6eba6fd3..8976dd03 100644 --- a/docker/test_docker.sh +++ b/docker/test_docker.sh @@ -39,9 +39,11 @@ docker run \ --name $container $tag echo "> Making sure the service is ready to accept requests" +# lets do something which will definitely not influence caches: hurl --retry=20 --retry-interval=5000 \ - --test "$docker_dir"/tests/create_resolve_exists.hurl || + --test "$docker_dir"/tests/resolve-nonexisting.hurl || exit 1 +sleep 1 echo "> Service is ready. Starting with actual tests." ##################################### diff --git a/docker/tests/resolve-nonexisting.hurl b/docker/tests/resolve-nonexisting.hurl new file mode 100644 index 00000000..4438974e --- /dev/null +++ b/docker/tests/resolve-nonexisting.hurl @@ -0,0 +1,4 @@ +GET http://localhost:8090/api/v1/pit/pid/sandboxed/does-not-exist +# This test is used to determine if the service is up and running. + +HTTP 404 From 4c9350b8831728dff18d9619abbfafd9c70b7474 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 22 Jan 2025 16:11:02 +0100 Subject: [PATCH 047/108] cleanup: remove TODO comment --- .../java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java index a7ab2bcf..cd3ce840 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java @@ -54,7 +54,6 @@ public TypeApi(ApplicationProperties properties, SchemaSetGenerator schemaSetGen } this.http = RestClient.builder().baseUrl(baseUri).build(); - // TODO better name caching properties (and consider extending them) int maximumSize = properties.getCacheMaxEntries(); long expireAfterWrite = properties.getCacheExpireAfterWriteLifetime(); From a3c496b678f7a7c9b625bda610ebd1984a5541df Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 22 Jan 2025 16:11:54 +0100 Subject: [PATCH 048/108] benchmarks: wait longer between health check and starting the requests --- docker/test_docker.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) mode change 100644 => 100755 docker/test_docker.sh diff --git a/docker/test_docker.sh b/docker/test_docker.sh old mode 100644 new mode 100755 index 8976dd03..6121b547 --- a/docker/test_docker.sh +++ b/docker/test_docker.sh @@ -41,9 +41,8 @@ docker run \ echo "> Making sure the service is ready to accept requests" # lets do something which will definitely not influence caches: hurl --retry=20 --retry-interval=5000 \ - --test "$docker_dir"/tests/resolve-nonexisting.hurl || - exit 1 -sleep 1 + --test "$docker_dir"/tests/resolve-nonexisting.hurl || exit 1 +sleep 3 echo "> Service is ready. Starting with actual tests." ##################################### From 8fe47fc6781ed08457cedc09d0c0efbccf7359c7 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 22 Jan 2025 16:40:35 +0100 Subject: [PATCH 049/108] benchmarks: avoid virtual threads for now until the ecosystem is more mature --- src/main/java/edu/kit/datamanager/pit/Application.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/edu/kit/datamanager/pit/Application.java b/src/main/java/edu/kit/datamanager/pit/Application.java index 5a252b80..c90a3369 100644 --- a/src/main/java/edu/kit/datamanager/pit/Application.java +++ b/src/main/java/edu/kit/datamanager/pit/Application.java @@ -92,7 +92,7 @@ public Logger logger(InjectionPoint injectionPoint) { } public static ExecutorService newExecutor() { - return Executors.newVirtualThreadPerTaskExecutor(); + return Executors.newThreadPerTaskExecutor(Executors.defaultThreadFactory()); } @Bean From ad429bac5a238e6813b9a9e4d9b88a60ba02cb46 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 22 Jan 2025 16:50:51 +0100 Subject: [PATCH 050/108] benchmarks: add first results --- docker/README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docker/README.md b/docker/README.md index f3d993ea..5b211860 100644 --- a/docker/README.md +++ b/docker/README.md @@ -12,7 +12,7 @@ There are two images: - Used in `test_docker.sh` and build/test CI. -## Run all tests +## Purpose For the recommended usage instructions, see the main readme file. The test script `docker_tests.sh` will: @@ -31,4 +31,16 @@ The idea behind the tests is to have basic tests from a very practical perspecti The goal is **not** to achieve full test coverage here. For this, we have unit tests and integration tests in `src/test`. -The benchmark mode is available to see the impact of new developments on the validation. \ No newline at end of file +The benchmark mode is available to see the impact of new developments on the validation time needed. + +## Benchmark results + +Let's collect some results of the benchmarks: + +- Mac Studio with M1 Max, 32GB RAM, Sonoma 14.7.2 + - Commit: 8fe47fc6781ed08457cedc09d0c0efbccf7359c7 + - Executed files: 1000 + - Executed requests: 1001 (6.2/s) + - Succeeded files: 1000 (100.0%) + - Failed files: 0 (0.0%) + - Duration: 160903 ms \ No newline at end of file From e4aa487afdcad95297f2c14a83bea1ae5d859ae1 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 22 Jan 2025 17:06:09 +0100 Subject: [PATCH 051/108] CI: test zulu now that we do not use virtual threads anymore --- .github/workflows/gradle.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index aea104db..f3382a5a 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -18,7 +18,6 @@ on: env: COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} - JDK_DISTRO: 'temurin' jobs: build: @@ -27,7 +26,8 @@ jobs: strategy: matrix: operating-system: [ubuntu-latest, macOS-latest, windows-latest] - jdk: [ 21 ] # (open)JDK releases + jdk: [ 21 ] + distro: ['temurin', 'zulu'] steps: - uses: actions/checkout@v4 @@ -35,7 +35,7 @@ jobs: uses: actions/setup-java@v4 with: java-version: ${{ matrix.jdk }} - distribution: ${{ env.JDK_DISTRO }} + distribution: ${{ matrix.distro }} - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - name: Build and Test with Gradle @@ -46,7 +46,8 @@ jobs: - name: Docker build and test if: matrix.operating-system == 'ubuntu-latest' && matrix.jdk == 21 run: | - curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/4.0.0/hurl_4.0.0_amd64.deb - sudo dpkg -i hurl_4.0.0_amd64.deb + VERSION=6.0.0 + curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/$VERSION/hurl_${VERSION}_amd64.deb + sudo apt update && sudo apt install ./hurl_${VERSION}_amd64.deb time bash ./docker/test_docker.sh shell: bash From 81f9d23355e52fd905489fa52b058e1aac58947a Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 22 Jan 2025 17:24:08 +0100 Subject: [PATCH 052/108] CI: remove zulu --- .github/workflows/gradle.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index f3382a5a..21d4d1c4 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -27,7 +27,7 @@ jobs: matrix: operating-system: [ubuntu-latest, macOS-latest, windows-latest] jdk: [ 21 ] - distro: ['temurin', 'zulu'] + distro: ['temurin'] steps: - uses: actions/checkout@v4 From 85e1ddf30850318dfb293031bcd3c8461b5b50ff Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 22 Jan 2025 18:09:36 +0100 Subject: [PATCH 053/108] docs: fix information about validation times --- .../pit/web/ITypingRestResource.java | 64 ++++++------------- 1 file changed, 19 insertions(+), 45 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java index e08e1169..8e5158b0 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java +++ b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java @@ -52,26 +52,6 @@ */ public interface ITypingRestResource { - /** - * Create a new PID using the record information provided in the request body. - * The record is expected to contain the identifier of the matching profile. - * Before creating the record, the record information will be validated against - * the profile. - * - * Important note: Validation caches recently used type information locally. - * Therefore, changes in a registry may take a few minutes to be reflected - * within the Typed PID Maker. This speeds up validation drastically in most - * situations. But it also means that, if the cache is empty, validation may - * take 30+ seconds. We are aware of the issue and considering improvements. But - * be aware that in general, validation may take up some time. - * - * @param rec The PID record. - * - * @return either 201 and a record representation, or an error (see ApiResponse - * annotations and tests). - * - * @throws IOException - */ @PostMapping( path = "pid/", consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, @@ -79,7 +59,15 @@ public interface ITypingRestResource { ) @Operation( summary = "Create a new PID record", - description = "Create a new PID record using the record information from the request body." + description = "Create a new PID record using the record information from the request body." + + " The record may contain the identifier(s) of the matching profile(s)." + + " Before creating the record, the record information will be validated against" + + " the profile." + + " Validation takes some time, depending on the context. It depends a lot on the size" + + " of your record and the already cached information. This information is gathered" + + " from external services. If there are connection issues or hickups at these sites," + + " validation may even take up to a few seconds. Usually you can expect the request" + + " to be between 100ms up to 1000ms on a fast machine with reliable connections." ) @io.swagger.v3.oas.annotations.parameters.RequestBody( description = "The body containing all PID record values as they should be in the new PIDs record.", @@ -122,21 +110,6 @@ public ResponseEntity createPID( final UriComponentsBuilder uriBuilder ) throws IOException; - /** - * Update the given PIDs record using the information provided in the request - * body. The record is expected to contain the identifier of the matching - * profile. Conditions for a valid record are the same as for creation. - *

- * Important note: Validation may take up to 30+ seconds. For details, see the - * documentation of "POST /pid/". - * - * @param rec the PID record. - * @param dryrun if only validation shall be executed. - * - * @return the record (on success). - * - * @throws IOException if the record could not be updated. - */ @PutMapping( path = "pid/**", consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, @@ -144,7 +117,11 @@ public ResponseEntity createPID( ) @Operation( summary = "Update an existing PID record", - description = "Update an existing PID record using the record information from the request body." + description = "Update an existing PID record using the record information from the request body." + + " The record may contain the identifier(s) of the matching profiles. Conditions for a" + + " valid record are the same as for creation." + + " Important note: Validation may take some time. For details, see the documentation of" + + " \"POST /pid/\"." ) @io.swagger.v3.oas.annotations.parameters.RequestBody( description = "The body containing all PID record values as they should be after the update.", @@ -190,18 +167,15 @@ public ResponseEntity updatePID( final UriComponentsBuilder uriBuilder ) throws IOException; - /** - * Get the record of the given PID (or test if it exists). - * - * @return the record. - * - * @throws IOException - */ @GetMapping( path = "pid/**", produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} ) - @Operation(summary = "Get the record of the given PID.", description = "Get the record to the given PID, if it exists. No validation is performed by default.") + @Operation( + summary = "Get the record of the given PID.", + description = "Get the record to the given PID, if it exists. May also be used to test" + + " if a PID exists. No validation is performed by default." + ) @ApiResponses(value = { @ApiResponse( responseCode = "200", From a92bd4d02302ba00367ea14d7d82f01151263f0a Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Thu, 23 Jan 2025 15:48:51 +0100 Subject: [PATCH 054/108] feat: log warnings if a request took longer than 400ms --- .../edu/kit/datamanager/pit/Application.java | 6 ++++++ .../pit/typeregistry/impl/TypeApi.java | 15 ++++++++++++++- .../schema/DtrTestSchemaGenerator.java | 15 +++++++++++++++ .../schema/TypeApiSchemaGenerator.java | 19 ++++++++++++++++++- 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/Application.java b/src/main/java/edu/kit/datamanager/pit/Application.java index c90a3369..8f48ca2c 100644 --- a/src/main/java/edu/kit/datamanager/pit/Application.java +++ b/src/main/java/edu/kit/datamanager/pit/Application.java @@ -84,6 +84,12 @@ public class Application { protected static final String ERROR_COMMUNICATION = "Communication error: {}"; protected static final String ERROR_CONFIGURATION = "Configuration error: {}"; + /** + * This is a threshold considered very long for a http request. + * Usually used in logging context + */ + public static final long LONG_HTTP_REQUEST_THRESHOLD = 400; + @Bean @Scope("prototype") public Logger logger(InjectionPoint injectionPoint) { diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java index cd3ce840..26a6c2b9 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApi.java @@ -17,6 +17,7 @@ import edu.kit.datamanager.pit.typeregistry.schema.SchemaSetGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.client.ClientHttpResponse; import org.springframework.web.client.RestClient; import java.io.IOException; @@ -52,7 +53,19 @@ public TypeApi(ApplicationProperties properties, SchemaSetGenerator schemaSetGen } catch (URISyntaxException e) { throw new InvalidConfigException("Type-Api base url not valid: " + baseUrl); } - this.http = RestClient.builder().baseUrl(baseUri).build(); + this.http = RestClient.builder() + .baseUrl(baseUri) + .requestInterceptor((request, body, execution) -> { + long start = System.currentTimeMillis(); + ClientHttpResponse response = execution.execute(request, body); + long timeSpan = System.currentTimeMillis() - start; + boolean isLongRequest = timeSpan > Application.LONG_HTTP_REQUEST_THRESHOLD; + if (isLongRequest) { + LOG.warn("Long http request to {} ({}ms)", request.getURI(), timeSpan); + } + return response; + }) + .build(); int maximumSize = properties.getCacheMaxEntries(); long expireAfterWrite = properties.getCacheExpireAfterWriteLifetime(); diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java index 7c6f66c5..c01727ec 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java @@ -1,5 +1,6 @@ package edu.kit.datamanager.pit.typeregistry.schema; +import edu.kit.datamanager.pit.Application; import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.common.InvalidConfigException; import edu.kit.datamanager.pit.common.TypeNotFoundException; @@ -10,7 +11,10 @@ import org.json.JSONException; import org.json.JSONObject; import org.json.JSONTokener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpResponse; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; @@ -20,6 +24,7 @@ import java.net.http.HttpClient; public class DtrTestSchemaGenerator implements SchemaGenerator { + private static final Logger LOG = LoggerFactory.getLogger(DtrTestSchemaGenerator.class); protected static final String ORIGIN = "dtr-test"; protected final URI baseUrl; @@ -37,6 +42,16 @@ public DtrTestSchemaGenerator(@NotNull ApplicationProperties props) { this.http = RestClient.builder() .baseUrl(this.baseUrl.toString()) .requestFactory(new JdkClientHttpRequestFactory(httpClient)) + .requestInterceptor((request, body, execution) -> { + long start = System.currentTimeMillis(); + ClientHttpResponse response = execution.execute(request, body); + long timeSpan = System.currentTimeMillis() - start; + boolean isLongRequest = timeSpan > Application.LONG_HTTP_REQUEST_THRESHOLD; + if (isLongRequest) { + LOG.warn("Long http request to {} ({}ms)", request.getURI(), timeSpan); + } + return response; + }) .build(); } diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java index 8fd58a65..7555a7a4 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java @@ -1,5 +1,6 @@ package edu.kit.datamanager.pit.typeregistry.schema; +import edu.kit.datamanager.pit.Application; import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.common.InvalidConfigException; import edu.kit.datamanager.pit.common.TypeNotFoundException; @@ -10,7 +11,10 @@ import org.json.JSONException; import org.json.JSONObject; import org.json.JSONTokener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpResponse; import org.springframework.web.client.RestClient; import java.io.InputStream; @@ -18,6 +22,7 @@ import java.net.URL; public class TypeApiSchemaGenerator implements SchemaGenerator { + private static final Logger LOG = LoggerFactory.getLogger(TypeApiSchemaGenerator.class); protected final URL baseUrl; protected final RestClient http; @@ -30,7 +35,19 @@ public TypeApiSchemaGenerator(@NotNull ApplicationProperties props) { } catch (URISyntaxException e) { throw new InvalidConfigException("Type-Api base url not valid: " + baseUrl, e); } - this.http = RestClient.builder().baseUrl(baseUri).build(); + this.http = RestClient.builder() + .baseUrl(baseUri) + .requestInterceptor((request, body, execution) -> { + long start = System.currentTimeMillis(); + ClientHttpResponse response = execution.execute(request, body); + long timeSpan = System.currentTimeMillis() - start; + boolean isLongRequest = timeSpan > Application.LONG_HTTP_REQUEST_THRESHOLD; + if (isLongRequest) { + LOG.warn("Long http request to {} ({}ms)", request.getURI(), timeSpan); + } + return response; + }) + .build(); } @Override From 0927d2b526b6690a9ed878d0b58e89a1d4b9aa3c Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Fri, 24 Jan 2025 14:27:19 +0100 Subject: [PATCH 055/108] test: test for records with invalid values --- .../web/ExplicitValidationParametersTest.java | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java index a27713a0..956de0e0 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java @@ -47,13 +47,13 @@ /** * This is a dedicated test for the validation/dryrun parameters, available for the REST interface. - * + *

* It ensures that: * - validation is being executed * - no data is stored - * + *

* It uses the in-memory implementation for simplicity. - * + *

* Explicit validation parameters are: * - dryrun=true for creating a PID * - validation=true for resolving a PID @@ -68,8 +68,7 @@ class ExplicitValidationParametersTest { static final String EMPTY_RECORD = "{\"pid\": null, \"entries\": {}}"; - static final String RECORD = "{\"entries\":{\"21.T11148/076759916209e5d62bd5\":[{\"key\":\"21.T11148/076759916209e5d62bd5\",\"name\":\"kernelInformationProfile\",\"value\":\"21.T11148/301c6f04763a16f0f72a\"}],\"21.T11148/397d831aa3a9d18eb52c\":[{\"key\":\"21.T11148/397d831aa3a9d18eb52c\",\"name\":\"dateModified\",\"value\":\"2021-12-21T17:36:09.541+00:00\"}],\"21.T11148/8074aed799118ac263ad\":[{\"key\":\"21.T11148/8074aed799118ac263ad\",\"name\":\"digitalObjectPolicy\",\"value\":\"21.T11148/37d0f4689c6ea3301787\"}],\"21.T11148/92e200311a56800b3e47\":[{\"key\":\"21.T11148/92e200311a56800b3e47\",\"name\":\"etag\",\"value\":\"{ \\\"sha256sum\\\": \\\"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\\\" }\"}],\"21.T11148/aafd5fb4c7222e2d950a\":[{\"key\":\"21.T11148/aafd5fb4c7222e2d950a\",\"name\":\"dateCreated\",\"value\":\"2021-12-21T17:36:09.541+00:00\"}],\"21.T11148/b8457812905b83046284\":[{\"key\":\"21.T11148/b8457812905b83046284\",\"name\":\"digitalObjectLocation\",\"value\":\"https://test.repo/file001\"}],\"21.T11148/c692273deb2772da307f\":[{\"key\":\"21.T11148/c692273deb2772da307f\",\"name\":\"version\",\"value\":\"1.0.0\"}],\"21.T11148/c83481d4bf467110e7c9\":[{\"key\":\"21.T11148/c83481d4bf467110e7c9\",\"name\":\"digitalObjectType\",\"value\":\"21.T11148/ManuscriptPage\"}]},\"pid\":\"unregistered-18622\"}"; - + @Autowired private WebApplicationContext webApplicationContext; @@ -94,7 +93,7 @@ class ExplicitValidationParametersTest { private InMemoryIdentifierSystem inMemory; @BeforeEach - void setup() throws Exception { + void setup() { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext).build(); this.knownPidsDao.deleteAll(); this.typingService.setValidationStrategy( @@ -208,6 +207,31 @@ void testNontypeRecord() throws Exception { assertEquals(0, this.knownPidsDao.count()); } + @Test + void testRecordWithInvalidValue() throws Exception { + PIDRecord r = new PIDRecord(); + // valid attribute key, but wrong attribute value: + String urlType = "21.T11969/e0efc41346cda4ba84ca"; + r.addEntry(urlType, "", "not a url"); + this.mockMvc + .perform( + post("/api/v1/pit/pid/") + .param("dryrun", "true") + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding("utf-8") + .content(new ObjectMapper().writeValueAsString(r)) + .accept(MediaType.ALL) + ) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()) + .andExpect(MockMvcResultMatchers.jsonPath( + "$.detail", + Matchers.containsString("has a non-complying value"))); + + // we store PIDs only if the PID was created successfully + assertEquals(0, this.knownPidsDao.count()); + } + @Test void testRecordWithAdditionalAttribute() throws Exception { PIDRecord r = new PIDRecord(); From a29e029b690eeb761419bd1c9562579eec4eb29b Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Fri, 24 Jan 2025 14:28:04 +0100 Subject: [PATCH 056/108] cleanup: fix linter warnings --- .../kit/datamanager/pit/web/ITypingRestResourceTest.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/test/java/edu/kit/datamanager/pit/web/ITypingRestResourceTest.java b/src/test/java/edu/kit/datamanager/pit/web/ITypingRestResourceTest.java index 4a61c79f..00ea561d 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ITypingRestResourceTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ITypingRestResourceTest.java @@ -37,7 +37,7 @@ class ITypingRestResourceTest { private MockMvc mockMvc; @BeforeEach - void setup() throws Exception { + void setup() { this.mockMvc = MockMvcBuilders .webAppContextSetup(this.webApplicationContext) .build(); @@ -48,11 +48,9 @@ void setup() throws Exception { /** * Tests if the swagger ui and openapi definition is accessible. - * + *

* Note that this test is using mockMVC; it does probably not detect issues with * CSRF, but will recognize other kinds of internal issues. - * - * @throws Exception */ @Test void getOpenApiDefinition() throws Exception { From 76e4641970652904e91e6a798fc9811d296fbb77 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Fri, 24 Jan 2025 17:45:04 +0100 Subject: [PATCH 057/108] version: set version to v3.0.0-rc1 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index ed514959..1212cb0d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,4 +14,4 @@ systemProp.jdk.tls.client.protocols="TLSv1,TLSv1.1,TLSv1.2" -version=2.2.0-rc6 \ No newline at end of file +version=3.3.0-rc1 \ No newline at end of file From 4c17b004da9d2d7cb48a1c9e549cdd60288ff757 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Mon, 27 Jan 2025 17:21:48 +0100 Subject: [PATCH 058/108] fix: use a EOSC DTR type to avoid regular issues with dtr-test within the current version of the EOSC schema generator --- .../kit/datamanager/pit/web/RestWithHandleProtocolTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java index 2891b068..0bc06bf2 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java @@ -103,9 +103,11 @@ void testDryrunUpdateWithPidGiven() throws Exception { PIDRecord record = mapper.readValue(response.getContentAsString(), PIDRecord.class); // fix record, it is actually invalid... record.removeAllValuesOf("URL"); - String licenseUrl = "21.T11148/2f314c8fe5fb6a0063a8"; + // fix possible issue with this type in current state of type api + record.removeAllValuesOf("21.T11148/2f314c8fe5fb6a0063a8"); + String licenseUrl = "21.T11969/e0efc41346cda4ba84ca"; record.removeAllValuesOf(licenseUrl); - record.addEntry(licenseUrl, "{ \"licenseURL\": \"https://cdla.dev/permissive-2-0/\" }"); + record.addEntry(licenseUrl, "https://cdla.dev/permissive-2-0/"); this.mockMvc.perform( put(url) .param("dryrun", "true") From 61c449f255cd30e110229d3bfb39a0192b9e0b43 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 29 Jan 2025 15:18:20 +0100 Subject: [PATCH 059/108] Implement meta-resolver. It will check if we have control over the prefix of the PID we are trying to resolve, and if so, use the configured system as we likely have administrative access and may have a better connection or other advantages. Otherwise, use a read-only client to the handle system, to make sure we can resolve external PIDs, even if we only create sandboxed ones. --- .../edu/kit/datamanager/pit/Application.java | 6 ++ .../pit/common/PidNotFoundException.java | 19 +++- .../kit/datamanager/pit/domain/PIDRecord.java | 5 ++ .../pidsystem/impl/HandleProtocolAdapter.java | 26 +----- .../datamanager/pit/resolver/Resolver.java | 89 +++++++++++++++++++ .../pit/web/impl/TypingRESTResourceImpl.java | 8 +- .../pit/resolver/ResolverTest.java | 55 ++++++++++++ 7 files changed, 181 insertions(+), 27 deletions(-) create mode 100644 src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java create mode 100644 src/test/java/edu/kit/datamanager/pit/resolver/ResolverTest.java diff --git a/src/main/java/edu/kit/datamanager/pit/Application.java b/src/main/java/edu/kit/datamanager/pit/Application.java index 8f48ca2c..056ec5ee 100644 --- a/src/main/java/edu/kit/datamanager/pit/Application.java +++ b/src/main/java/edu/kit/datamanager/pit/Application.java @@ -30,6 +30,7 @@ import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; import edu.kit.datamanager.pit.pitservice.ITypingService; import edu.kit.datamanager.pit.pitservice.impl.TypingService; +import edu.kit.datamanager.pit.resolver.Resolver; import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import edu.kit.datamanager.pit.typeregistry.impl.TypeApi; import edu.kit.datamanager.pit.typeregistry.schema.SchemaSetGenerator; @@ -111,6 +112,11 @@ public ITypeRegistry typeRegistry(ApplicationProperties props, SchemaSetGenerato return new TypeApi(props, schemaSetGenerator); } + @Bean + public Resolver resolver(ITypingService identifierSystem) { + return new Resolver(identifierSystem); + } + @Bean public ITypingService typingService(IIdentifierSystem identifierSystem, ITypeRegistry typeRegistry) { return new TypingService(identifierSystem, typeRegistry); diff --git a/src/main/java/edu/kit/datamanager/pit/common/PidNotFoundException.java b/src/main/java/edu/kit/datamanager/pit/common/PidNotFoundException.java index 9131a2a3..11c50d18 100644 --- a/src/main/java/edu/kit/datamanager/pit/common/PidNotFoundException.java +++ b/src/main/java/edu/kit/datamanager/pit/common/PidNotFoundException.java @@ -4,20 +4,33 @@ import org.springframework.http.HttpStatus; +import java.io.Serial; + /** * Indicates that a PID was given which could not be resolved to answer the * request properly. */ public class PidNotFoundException extends ResponseStatusException { - private static final long serialVersionUID = 1L; + @Serial + private static final long serialVersionUID = 3362829471655054621L; private static final HttpStatus HTTP_STATUS = HttpStatus.NOT_FOUND; + public static final String ID_NOT_FOUND_MSG = "Identifier with value %s not found."; + public static final String REASON_MSG = "%s Reason: %s"; public PidNotFoundException(String pid) { - super(HTTP_STATUS, "Identifier with value " + pid + " not found."); + super(HTTP_STATUS, ID_NOT_FOUND_MSG.formatted(pid)); + } + + public PidNotFoundException(String pid, String reason) { + super(HTTP_STATUS, REASON_MSG.formatted(ID_NOT_FOUND_MSG.formatted(pid), reason)); + } + + public PidNotFoundException(String pid, String reason, Throwable e) { + super(HTTP_STATUS, REASON_MSG.formatted(ID_NOT_FOUND_MSG.formatted(pid), reason), e); } public PidNotFoundException(String pid, Throwable e) { - super(HTTP_STATUS, "Identifier with value " + pid + " not found.", e); + super(HTTP_STATUS, ID_NOT_FOUND_MSG.formatted(pid), e); } } diff --git a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java index d0a8f9db..bf50637d 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java @@ -4,6 +4,7 @@ import edu.kit.datamanager.entities.EtagSupport; import edu.kit.datamanager.pit.pidsystem.impl.local.PidDatabaseObject; +import net.handle.hdllib.HandleValue; import java.util.ArrayList; import java.util.Collection; @@ -52,6 +53,10 @@ public PIDRecord(SimplePidRecord rec) { } } + public PIDRecord(final Collection values) { + values.forEach(v -> this.addEntry(v.getTypeAsString(), "", v.getDataAsString())); + } + /** * Convenience setter / builder method. * diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java index ec516042..4065f7ee 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java @@ -168,9 +168,9 @@ public PIDRecord queryPid(final String pid) throws PidNotFoundException, Externa return null; } Collection recordProperties = Streams.failableStream(allValues.stream()) - .filter(value -> !this.isHandleInternalValue(value)) + .filter(value -> !isHandleInternalValue(value)) .collect(Collectors.toList()); - return this.pidRecordFrom(recordProperties).withPID(pid); + return new PIDRecord(recordProperties).withPID(pid); } @NotNull @@ -248,7 +248,7 @@ public boolean updatePid(final PIDRecord pidRecord) throws PidNotFoundException, .collect(Collectors.toMap(HandleValue::getIndex, v -> v)); // 1) List valuesToKeep = oldHandleValues.stream() - .filter(this::isHandleInternalValue) + .filter(HandleProtocolAdapter::isHandleInternalValue) .collect(Collectors.toList()); // 2) Merge requested record and things we want to keep. @@ -353,24 +353,6 @@ public Collection resolveAllPidsOfPrefix() throws ExternalServiceExcepti } } - /** - * Avoids an extra constructor in `PIDRecord`. Instead, - * keep such details stored in the PID service implementation. - * - * @param values HandleValue collection (ordering recommended) - * that shall be converted into a PIDRecord. - * @return a PID record with values copied from values. - */ - protected PIDRecord pidRecordFrom(final Collection values) { - PIDRecord result = new PIDRecord(); - for (HandleValue v : values) { - // TODO In future, the type could be resolved to store the human readable name - // here. - result.addEntry(v.getTypeAsString(), "", v.getDataAsString()); - } - return result; - } - /** * Convert a `PIDRecord` instance to an array of `HandleValue`s. It is the * inverse method to `pidRecordFrom`. @@ -467,7 +449,7 @@ protected boolean isValidPID(final String pid) { * @param v the value to check. * @return true, if the value is conidered "handle-native". */ - protected boolean isHandleInternalValue(HandleValue v) { + public static boolean isHandleInternalValue(HandleValue v) { boolean isInternalValue = false; for (byte[][] typeList : BLACKLIST_NONTYPE_LISTS) { for (byte[] typeCode : typeList) { diff --git a/src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java b/src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java new file mode 100644 index 00000000..b689a570 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java @@ -0,0 +1,89 @@ +package edu.kit.datamanager.pit.resolver; + +import edu.kit.datamanager.pit.common.ExternalServiceException; +import edu.kit.datamanager.pit.common.PidNotFoundException; +import edu.kit.datamanager.pit.domain.PIDRecord; +import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pitservice.ITypingService; +import net.handle.api.HSAdapter; +import net.handle.api.HSAdapterFactory; +import net.handle.hdllib.HandleException; +import net.handle.hdllib.HandleValue; + +import java.util.Arrays; +import java.util.Collection; +import java.util.stream.Collectors; + +/** + * Universal resolver. Will not only resolve PIDs from the configured PID system, + * but also from other external systems, to which it has read-only access to. + *

+ * Currently implemented read-only systems: + *

+ * - Handle System + */ +public class Resolver { + /** + * The configured system to which we usually have write access. + * Only used if the prefix of the PID matches the prefix of this system. + */ + private final ITypingService identifierSystem; + + /** + * The client to the Handle System, used in read-only mode. + * Used as a fallback, if the prefix is not the one of the configured system. + */ + private final HSAdapter client = HSAdapterFactory.newInstance(); + private static final String SERVICE_NAME_HANDLE = "Handle System (read-only access)"; + + public Resolver(ITypingService identifierSystem) { + this.identifierSystem = identifierSystem; + } + + /** + * Resolves a PID to a PIDRecord. + *

+ * Takes advantage of administrative access to the configured PID system, if possible. + * Otherwise, falls back to read-only access to external systems. + * + * @param pid the PID to resolve. + * @return the PIDRecord associated with the PID. + * @throws PidNotFoundException if the PID could not be found in any system. + * @throws ExternalServiceException if there was an error with the communication to an external system. + */ + public PIDRecord resolve(String pid) throws PidNotFoundException, ExternalServiceException { + String prefix = Arrays.stream( + pid.split("/", 2) + ) + .findFirst() + .orElseThrow(() -> new PidNotFoundException(pid, "Could not find prefix in PID.")) + + "/"; // needed because the prefix is always followed by a slash + boolean isInConfiguredIdentifierSystem = this.identifierSystem != null && this.identifierSystem.getPrefix() + .map(prefix::equals) + .orElse(false); + if (isInConfiguredIdentifierSystem) { + return this.identifierSystem.queryPid(pid); + } else { + try { + Collection recordProperties = Arrays.stream(this.client.resolveHandle(pid, null, null)) + .filter(value -> !HandleProtocolAdapter.isHandleInternalValue(value)) + .collect(Collectors.toList()); + return new PIDRecord(recordProperties).withPID(pid); + } catch (HandleException e) { + int code = e.getCode(); + boolean isExistingPid = code == HandleException.HANDLE_DOES_NOT_EXIST; + boolean missingPrefixHost = false; + if (e.getCause() instanceof HandleException inner) { + int innerCode = inner.getCode(); + missingPrefixHost = innerCode == HandleException.SERVICE_NOT_FOUND + || innerCode == HandleException.HANDLE_DOES_NOT_EXIST; + } + if (isExistingPid || missingPrefixHost) { + throw new PidNotFoundException(pid, e); + } else { + throw new ExternalServiceException(SERVICE_NAME_HANDLE, e); + } + } + } + } +} diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index 982dd839..65ee8ffb 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -19,6 +19,7 @@ import edu.kit.datamanager.pit.pidlog.KnownPid; import edu.kit.datamanager.pit.pidlog.KnownPidsDao; import edu.kit.datamanager.pit.pitservice.ITypingService; +import edu.kit.datamanager.pit.resolver.Resolver; import edu.kit.datamanager.pit.web.ITypingRestResource; import edu.kit.datamanager.pit.web.TabulatorPaginationFormat; import edu.kit.datamanager.service.IMessagingService; @@ -59,6 +60,9 @@ public class TypingRESTResourceImpl implements ITypingRestResource { @Autowired protected ITypingService typingService; + @Autowired + protected Resolver resolver; + @Autowired private IMessagingService messagingService; @@ -176,7 +180,7 @@ public ResponseEntity updatePID( "Optional PID in record is given (%s), but it was not the same as the PID in the URL (%s). Ignore request, assuming this was not intended.".formatted(pidInternal, pid)); } - PIDRecord existingRecord = this.typingService.queryPid(pid); + PIDRecord existingRecord = this.resolver.resolve(pid); if (existingRecord == null) { throw new PidNotFoundException(pid); } @@ -251,7 +255,7 @@ public ResponseEntity getRecord( final UriComponentsBuilder uriBuilder ) throws IOException { String pid = getContentPathFromRequest("pid", request); - PIDRecord pidRecord = this.typingService.queryPid(pid); + PIDRecord pidRecord = this.resolver.resolve(pid); if (applicationProps.getStorageStrategy().storesResolved()) { storeLocally(pid, false); } diff --git a/src/test/java/edu/kit/datamanager/pit/resolver/ResolverTest.java b/src/test/java/edu/kit/datamanager/pit/resolver/ResolverTest.java new file mode 100644 index 00000000..db26ecfa --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/resolver/ResolverTest.java @@ -0,0 +1,55 @@ +package edu.kit.datamanager.pit.resolver; + +import edu.kit.datamanager.pit.common.PidNotFoundException; +import edu.kit.datamanager.pit.domain.PIDRecord; +import edu.kit.datamanager.pit.pitservice.ITypingService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@TestPropertySource("/test/application-test.properties") +@ActiveProfiles("test") +class ResolverTest { + + @Autowired + ITypingService identifierSystem; + + Resolver resolver; + + @BeforeEach + void setUp() { + resolver = new Resolver(this.identifierSystem); + } + + @Test + void resolveWithoutPrefix() { + assertThrows(PidNotFoundException.class, () -> resolver.resolve("test")); + } + + @Test + void resolveWithNonexistentPrefix() { + assertThrows(PidNotFoundException.class, () -> resolver.resolve("nonexistentprefix/test")); + } + + @Test + void resolveHandleReadOnly() { + PIDRecord result = resolver.resolve("10.5281/zenodo.8014937"); + assertNotNull(result); + } + + @Test + void resolveInMemory() { + PIDRecord record = new PIDRecord().withPID("suffix"); + record.addEntry("key", "value"); + String pid = this.identifierSystem.registerPid(record); + PIDRecord result = resolver.resolve(pid); + assertNotNull(result); + assertEquals("value", result.getEntries().get("key").getFirst().getValue()); + } +} \ No newline at end of file From 2e0b6dbc09199542cfceedc0a019f307ac789bf3 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 29 Jan 2025 16:12:14 +0100 Subject: [PATCH 060/108] chore: more ergonomic implementation of index calculation in HandleIndex --- .../pidsystem/impl/{ => handle}/HandleProtocolAdapter.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) rename src/main/java/edu/kit/datamanager/pit/pidsystem/impl/{ => handle}/HandleProtocolAdapter.java (99%) diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java similarity index 99% rename from src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java rename to src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java index 4065f7ee..e030685b 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapter.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java @@ -400,10 +400,9 @@ protected static class HandleIndex { public final int nextIndex() { int result = index; - index += 1; - if (index == this.getHsAdminIndex() || skipping.contains(index)) { + do { index += 1; - } + } while (index == this.getHsAdminIndex() || skipping.contains(index)); return result; } From 58f9984fe6a6bd1389b5da5c11f76049b7d23fbc Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 29 Jan 2025 16:13:40 +0100 Subject: [PATCH 061/108] chore: optimize record-to-handleValues conversion and explicitly uncouple it from the class instance. --- .../pit/pidsystem/impl/handle/HandleProtocolAdapter.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java index e030685b..6938bb5a 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java @@ -198,7 +198,7 @@ public String registerPidUnchecked(final PIDRecord pidRecord) throws PidAlreadyE for (RecordModifier modifier : this.props.getConfiguredModifiers()) { preparedRecord = modifier.apply(preparedRecord); } - ArrayList futurePairs = this.handleValuesFrom(preparedRecord, Optional.of(admin)); + ArrayList futurePairs = handleValuesFrom(preparedRecord, Optional.of(admin)); HandleValue[] futurePairsArray = futurePairs.toArray(new HandleValue[] {}); @@ -362,7 +362,7 @@ public Collection resolveAllPidsOfPrefix() throws ExternalServiceExcepti * @return HandleValues containing the same key-value pairs as the given record, * but e.g. without the name. */ - protected ArrayList handleValuesFrom( + protected static ArrayList handleValuesFrom( final PIDRecord pidRecord, final Optional> toMerge) { @@ -389,7 +389,7 @@ protected ArrayList handleValuesFrom( LOG.debug("Entry: ({}) {} <-> {}", i, key, val); } } - assert result.size() >= pidRecord.getEntries().keySet().size(); + assert result.size() >= pidRecord.getEntries().size(); return result; } From 919c4de3322dbc4d3683a53ab63ce86f2d895a2f Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 29 Jan 2025 16:18:47 +0100 Subject: [PATCH 062/108] chore: move HandleProtocolAdapter to its own module --- .../pit/pidsystem/impl/handle/HandleProtocolAdapter.java | 2 +- src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java | 2 +- .../pit/configuration/HandleProtocolSetupTest.java | 2 +- .../kit/datamanager/pit/configuration/InMemorySetupTest.java | 2 +- .../datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java | 2 +- .../impl/{ => handle}/HandleProtocolAdapterTest.java | 4 ++-- src/test/java/edu/kit/datamanager/pit/web/EtagTest.java | 2 +- .../datamanager/pit/web/ExplicitValidationParametersTest.java | 2 +- .../kit/datamanager/pit/web/RestWithHandleProtocolTest.java | 2 +- .../edu/kit/datamanager/pit/web/RestWithInMemoryTest.java | 3 +-- .../kit/datamanager/pit/web/RestWithLocalPidSystemTest.java | 2 +- 11 files changed, 12 insertions(+), 13 deletions(-) rename src/test/java/edu/kit/datamanager/pit/pidsystem/impl/{ => handle}/HandleProtocolAdapterTest.java (95%) diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java index 6938bb5a..e5c42c41 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java @@ -1,4 +1,4 @@ -package edu.kit.datamanager.pit.pidsystem.impl; +package edu.kit.datamanager.pit.pidsystem.impl.handle; import java.io.IOException; import java.nio.charset.StandardCharsets; diff --git a/src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java b/src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java index b689a570..e9a03623 100644 --- a/src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java +++ b/src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java @@ -3,7 +3,7 @@ import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.common.PidNotFoundException; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pitservice.ITypingService; import net.handle.api.HSAdapter; import net.handle.api.HSAdapterFactory; diff --git a/src/test/java/edu/kit/datamanager/pit/configuration/HandleProtocolSetupTest.java b/src/test/java/edu/kit/datamanager/pit/configuration/HandleProtocolSetupTest.java index 11bd3630..49c3fa5e 100644 --- a/src/test/java/edu/kit/datamanager/pit/configuration/HandleProtocolSetupTest.java +++ b/src/test/java/edu/kit/datamanager/pit/configuration/HandleProtocolSetupTest.java @@ -13,7 +13,7 @@ import edu.kit.datamanager.pit.SpringTestHelper; import edu.kit.datamanager.pit.configuration.ApplicationProperties.IdentifierSystemImpl; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; @SpringBootTest() diff --git a/src/test/java/edu/kit/datamanager/pit/configuration/InMemorySetupTest.java b/src/test/java/edu/kit/datamanager/pit/configuration/InMemorySetupTest.java index 0092af73..8cc006ee 100644 --- a/src/test/java/edu/kit/datamanager/pit/configuration/InMemorySetupTest.java +++ b/src/test/java/edu/kit/datamanager/pit/configuration/InMemorySetupTest.java @@ -12,7 +12,7 @@ import edu.kit.datamanager.pit.SpringTestHelper; import edu.kit.datamanager.pit.configuration.ApplicationProperties.IdentifierSystemImpl; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java index 791f1d19..e3d30253 100644 --- a/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/IIdentifierSystemQueryTest.java @@ -15,7 +15,7 @@ import edu.kit.datamanager.pit.common.PidNotFoundException; import edu.kit.datamanager.pit.configuration.HandleProtocolProperties; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; import net.handle.hdllib.HandleException; diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapterTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapterTest.java similarity index 95% rename from src/test/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapterTest.java rename to src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapterTest.java index 04593b0e..811be6ed 100644 --- a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/HandleProtocolAdapterTest.java +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapterTest.java @@ -1,4 +1,4 @@ -package edu.kit.datamanager.pit.pidsystem.impl; +package edu.kit.datamanager.pit.pidsystem.impl.handle; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -7,7 +7,7 @@ import org.junit.jupiter.api.Test; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter.HandleDiff; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter.HandleDiff; import net.handle.hdllib.HandleValue; class HandleProtocolAdapterTest { diff --git a/src/test/java/edu/kit/datamanager/pit/web/EtagTest.java b/src/test/java/edu/kit/datamanager/pit/web/EtagTest.java index 1210d492..ca6ff104 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/EtagTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/EtagTest.java @@ -5,7 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import edu.kit.datamanager.pit.SpringTestHelper; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; import edu.kit.datamanager.pit.pidsystem.impl.local.LocalPidSystem; import org.apache.http.HttpHeaders; diff --git a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java index 956de0e0..64bf081e 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java @@ -21,7 +21,7 @@ import edu.kit.datamanager.pit.domain.SimplePidRecord; import edu.kit.datamanager.pit.pidgeneration.PidSuffixGenerator; import edu.kit.datamanager.pit.pidlog.KnownPidsDao; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; import edu.kit.datamanager.pit.pitservice.ITypingService; import edu.kit.datamanager.pit.pitservice.impl.NoValidationStrategy; diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java index 0bc06bf2..d5fcd69b 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java @@ -19,7 +19,7 @@ import org.springframework.web.context.WebApplicationContext; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; import static org.junit.jupiter.api.Assertions.*; diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java index 85401760..c341e05c 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java @@ -12,7 +12,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -30,7 +29,7 @@ import edu.kit.datamanager.pit.pidgeneration.PidSuffixGenerator; import edu.kit.datamanager.pit.pidlog.KnownPid; import edu.kit.datamanager.pit.pidlog.KnownPidsDao; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; import edu.kit.datamanager.pit.pitservice.ITypingService; import edu.kit.datamanager.pit.pitservice.impl.NoValidationStrategy; diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithLocalPidSystemTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithLocalPidSystemTest.java index 59c84640..1fc07585 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithLocalPidSystemTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithLocalPidSystemTest.java @@ -30,7 +30,7 @@ import edu.kit.datamanager.pit.pidgeneration.PidSuffixGenerator; import edu.kit.datamanager.pit.pidlog.KnownPid; import edu.kit.datamanager.pit.pidlog.KnownPidsDao; -import edu.kit.datamanager.pit.pidsystem.impl.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; import edu.kit.datamanager.pit.pidsystem.impl.InMemoryIdentifierSystem; import edu.kit.datamanager.pit.pidsystem.impl.local.LocalPidSystem; import edu.kit.datamanager.pit.pitservice.ITypingService; From 76236f5aee689a016b0bb3368c097abcd5c46005 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 29 Jan 2025 17:36:05 +0100 Subject: [PATCH 063/108] chore: split HandleProtocolAdapter into different responsibilities. --- .../kit/datamanager/pit/domain/PIDRecord.java | 6 - .../pidsystem/impl/handle/HandleBehavior.java | 122 +++++++++++ .../pit/pidsystem/impl/handle/HandleDiff.java | 82 ++++++++ .../pidsystem/impl/handle/HandleIndex.java | 30 +++ .../impl/handle/HandleProtocolAdapter.java | 198 +----------------- .../datamanager/pit/resolver/Resolver.java | 6 +- ...olAdapterTest.java => HandleDiffTest.java} | 7 +- .../impl/handle/HandleIndexTest.java | 39 ++++ 8 files changed, 285 insertions(+), 205 deletions(-) create mode 100644 src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleBehavior.java create mode 100644 src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleDiff.java create mode 100644 src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleIndex.java rename src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/{HandleProtocolAdapterTest.java => HandleDiffTest.java} (91%) create mode 100644 src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleIndexTest.java diff --git a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java index bf50637d..9d67861e 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java @@ -4,12 +4,10 @@ import edu.kit.datamanager.entities.EtagSupport; import edu.kit.datamanager.pit.pidsystem.impl.local.PidDatabaseObject; -import net.handle.hdllib.HandleValue; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; -import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -53,10 +51,6 @@ public PIDRecord(SimplePidRecord rec) { } } - public PIDRecord(final Collection values) { - values.forEach(v -> this.addEntry(v.getTypeAsString(), "", v.getDataAsString())); - } - /** * Convenience setter / builder method. * diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleBehavior.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleBehavior.java new file mode 100644 index 00000000..59ec8055 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleBehavior.java @@ -0,0 +1,122 @@ +package edu.kit.datamanager.pit.pidsystem.impl.handle; + +import edu.kit.datamanager.pit.domain.PIDRecord; +import edu.kit.datamanager.pit.domain.PIDRecordEntry; +import net.handle.hdllib.Common; +import net.handle.hdllib.HandleValue; + +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * This class defines helper functions and constants which define special + * behavior in the Typed PID Maker context. + *

+ * This has the purpose of reuse, e.g. in the resolver module, but also + * separating the authentication and PID logic from certain behavior aspects. + *

+ * In later aspects, this may be extended to implement an interface and use the + * strategy pattern in order to make the behavior configurable. + */ +public class HandleBehavior { + + /** + * A list of type codes which are considered "internal" or "handle-native" + * and should not be exposed. Use `isHandleInternalValue` to check if a + * value should be filtered out. + */ + private static final byte[][][] BLACKLIST_NONTYPE_LISTS = { + Common.SITE_INFO_AND_SERVICE_HANDLE_INCL_PREFIX_TYPES, + Common.DERIVED_PREFIX_SITE_AND_SERVICE_HANDLE_TYPES, + Common.SERVICE_HANDLE_TYPES, + Common.LOCATION_AND_ADMIN_TYPES, + Common.SECRET_KEY_TYPES, + Common.PUBLIC_KEY_TYPES, + // Common.STD_TYPES, // not using because of URL and EMAIL + { + // URL and EMAIL might contain valuable information and can be considered + // non-technical. + // Common.STD_TYPE_URL, + // Common.STD_TYPE_EMAIL, + Common.STD_TYPE_HSADMIN, + Common.STD_TYPE_HSALIAS, + Common.STD_TYPE_HSSITE, + Common.STD_TYPE_HSSITE6, + Common.STD_TYPE_HSSERV, + Common.STD_TYPE_HSSECKEY, + Common.STD_TYPE_HSPUBKEY, + Common.STD_TYPE_HSVALLIST, + } + }; + + /** + * This class is not meant to be instantiated. + */ + private HandleBehavior() {} + + /** + * Checks if a given value is considered an "internal" or "handle-native" value. + *

+ * This may be used to filter out administrative information from a PID record. + * + * @param v the value to check. + * @return true, if the value is considered "handle-native". + */ + public static boolean isHandleInternalValue(HandleValue v) { + boolean isInternalValue = false; + for (byte[][] typeList : BLACKLIST_NONTYPE_LISTS) { + for (byte[] typeCode : typeList) { + isInternalValue = isInternalValue || Arrays.equals(v.getType(), typeCode); + } + } + return isInternalValue; + } + + /** + * Convert a `PIDRecord` instance to an array of `HandleValue`s. It is the + * inverse method to `pidRecordFrom`. + * + * @param pidRecord the record containing values to convert / extract. + * @param toMerge an optional list to merge the result with. + * @return HandleValues containing the same key-value pairs as the given record, + * but e.g. without the name. + */ + public static ArrayList handleValuesFrom( + final PIDRecord pidRecord, + final Optional> toMerge) + { + ArrayList skippingIndices = new ArrayList<>(); + ArrayList result = new ArrayList<>(); + if (toMerge.isPresent()) { + for (HandleValue v : toMerge.get()) { + result.add(v); + skippingIndices.add(v.getIndex()); + } + } + HandleIndex index = new HandleIndex().skipping(skippingIndices); + Map> entries = pidRecord.getEntries(); + + for (Map.Entry> entry : entries.entrySet()) { + for (PIDRecordEntry val : entry.getValue()) { + String key = val.getKey(); + HandleValue hv = new HandleValue(); + int i = index.nextIndex(); + hv.setIndex(i); + hv.setType(key.getBytes(StandardCharsets.UTF_8)); + hv.setData(val.getValue().getBytes(StandardCharsets.UTF_8)); + result.add(hv); + } + } + assert result.size() >= pidRecord.getEntries().size(); + return result; + } + + public static PIDRecord recordFrom(final Collection values) { + PIDRecord record = new PIDRecord(); + values.forEach(v -> record.addEntry( + v.getTypeAsString(), + v.getDataAsString()) + ); + return record; + } +} diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleDiff.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleDiff.java new file mode 100644 index 00000000..4d1a2e37 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleDiff.java @@ -0,0 +1,82 @@ +package edu.kit.datamanager.pit.pidsystem.impl.handle; + +import edu.kit.datamanager.pit.common.PidUpdateException; +import net.handle.hdllib.HandleValue; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +/** + * Given two Value Maps, it splits the values in those which have been added, + * updated or removed. + * Using this lists, an update can be applied to the old record, to bring it to + * the state of the new record. + */ +class HandleDiff { + private final Collection toAdd = new ArrayList<>(); + private final Collection toUpdate = new ArrayList<>(); + private final Collection toRemove = new ArrayList<>(); + + HandleDiff( + final Map recordOld, + final Map recordNew + ) throws PidUpdateException { + for (Map.Entry old : recordOld.entrySet()) { + boolean wasRemoved = !recordNew.containsKey(old.getKey()); + if (wasRemoved) { + // if a row in the record is not available anymore, we need to delete it + toRemove.add(old.getValue()); + } else { + // otherwise, we should go and update it. + // we could also check for equality, but this is the safe and easy way. + // (the handlevalue classes can be complicated and we'd have to check their + // equality implementation) + toUpdate.add(recordNew.get(old.getKey())); + } + } + for (Map.Entry e : recordNew.entrySet()) { + boolean isNew = !recordOld.containsKey(e.getKey()); + if (isNew) { + // if there is a record which is not in the oldRecord, we need to add it. + toAdd.add(e.getValue()); + } + } + + // runtime testing to avoid messing up record states. + String exceptionMsg = "DIFF NOT VALID. Type: %s. Value: %s"; + for (HandleValue v : toRemove) { + boolean valid = recordOld.containsValue(v) && !recordNew.containsKey(v.getIndex()); + if (!valid) { + String message = String.format(exceptionMsg, "Remove", v.toString()); + throw new PidUpdateException(message); + } + } + for (HandleValue v : toAdd) { + boolean valid = !recordOld.containsKey(v.getIndex()) && recordNew.containsValue(v); + if (!valid) { + String message = String.format(exceptionMsg, "Add", v); + throw new PidUpdateException(message); + } + } + for (HandleValue v : toUpdate) { + boolean valid = recordOld.containsKey(v.getIndex()) && recordNew.containsValue(v); + if (!valid) { + String message = String.format(exceptionMsg, "Update", v); + throw new PidUpdateException(message); + } + } + } + + public HandleValue[] added() { + return this.toAdd.toArray(new HandleValue[] {}); + } + + public HandleValue[] updated() { + return this.toUpdate.toArray(new HandleValue[] {}); + } + + public HandleValue[] removed() { + return this.toRemove.toArray(new HandleValue[] {}); + } +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleIndex.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleIndex.java new file mode 100644 index 00000000..8349dad3 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleIndex.java @@ -0,0 +1,30 @@ +package edu.kit.datamanager.pit.pidsystem.impl.handle; + +import java.util.ArrayList; +import java.util.List; + +/** + * This class allows iterating over handle indices, skipping administrative ones. + */ +class HandleIndex { + // handle record indices start at 1 + private int index = 1; + private List skipping = new ArrayList<>(); + + public int nextIndex() { + int result = index; + do { + index += 1; + } while (index == this.getHsAdminIndex() || skipping.contains(index)); + return result; + } + + public HandleIndex skipping(List skipThose) { + this.skipping = skipThose; + return this; + } + + public int getHsAdminIndex() { + return 100; + } +} \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java index e5c42c41..5898bc27 100644 --- a/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java +++ b/src/main/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapter.java @@ -1,16 +1,13 @@ package edu.kit.datamanager.pit.pidsystem.impl.handle; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.security.PrivateKey; import java.security.spec.InvalidKeySpecException; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Map.Entry; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -28,17 +25,14 @@ import edu.kit.datamanager.pit.common.InvalidConfigException; import edu.kit.datamanager.pit.common.PidAlreadyExistsException; import edu.kit.datamanager.pit.common.PidNotFoundException; -import edu.kit.datamanager.pit.common.PidUpdateException; import edu.kit.datamanager.pit.common.RecordValidationException; import edu.kit.datamanager.pit.configuration.HandleCredentials; import edu.kit.datamanager.pit.configuration.HandleProtocolProperties; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.PIDRecordEntry; import edu.kit.datamanager.pit.pidsystem.IIdentifierSystem; import net.handle.api.HSAdapter; import net.handle.api.HSAdapterFactory; import net.handle.apps.batch.BatchUtil; -import net.handle.hdllib.Common; import net.handle.hdllib.HandleException; import net.handle.hdllib.HandleResolver; import net.handle.hdllib.HandleValue; @@ -56,30 +50,6 @@ public class HandleProtocolAdapter implements IIdentifierSystem { private static final Logger LOG = LoggerFactory.getLogger(HandleProtocolAdapter.class); - private static final byte[][][] BLACKLIST_NONTYPE_LISTS = { - Common.SITE_INFO_AND_SERVICE_HANDLE_INCL_PREFIX_TYPES, - Common.DERIVED_PREFIX_SITE_AND_SERVICE_HANDLE_TYPES, - Common.SERVICE_HANDLE_TYPES, - Common.LOCATION_AND_ADMIN_TYPES, - Common.SECRET_KEY_TYPES, - Common.PUBLIC_KEY_TYPES, - // Common.STD_TYPES, // not using because of URL and EMAIL - { - // URL and EMAIL might contain valuable information and can be considered - // non-technical. - // Common.STD_TYPE_URL, - // Common.STD_TYPE_EMAIL, - Common.STD_TYPE_HSADMIN, - Common.STD_TYPE_HSALIAS, - Common.STD_TYPE_HSSITE, - Common.STD_TYPE_HSSITE6, - Common.STD_TYPE_HSSERV, - Common.STD_TYPE_HSSECKEY, - Common.STD_TYPE_HSPUBKEY, - Common.STD_TYPE_HSVALLIST, - } - }; - private static final String SERVICE_NAME_HANDLE = "Handle System"; // Properties specific to this adapter. @@ -129,11 +99,10 @@ public void init() throws InvalidConfigException, HandleException, IOException { privateKey, passphrase // "use null for unencrypted keys" ); - HandleIndex indexManager = new HandleIndex(); this.adminValue = this.client.createAdminValue( props.getCredentials().getUserHandle(), props.getCredentials().getPrivateKeyIndex(), - indexManager.getHsAdminIndex()); + new HandleIndex().getHsAdminIndex()); } } @@ -168,9 +137,9 @@ public PIDRecord queryPid(final String pid) throws PidNotFoundException, Externa return null; } Collection recordProperties = Streams.failableStream(allValues.stream()) - .filter(value -> !isHandleInternalValue(value)) + .filter(value -> !HandleBehavior.isHandleInternalValue(value)) .collect(Collectors.toList()); - return new PIDRecord(recordProperties).withPID(pid); + return HandleBehavior.recordFrom(recordProperties).withPID(pid); } @NotNull @@ -198,7 +167,7 @@ public String registerPidUnchecked(final PIDRecord pidRecord) throws PidAlreadyE for (RecordModifier modifier : this.props.getConfiguredModifiers()) { preparedRecord = modifier.apply(preparedRecord); } - ArrayList futurePairs = handleValuesFrom(preparedRecord, Optional.of(admin)); + ArrayList futurePairs = HandleBehavior.handleValuesFrom(preparedRecord, Optional.of(admin)); HandleValue[] futurePairsArray = futurePairs.toArray(new HandleValue[] {}); @@ -248,11 +217,11 @@ public boolean updatePid(final PIDRecord pidRecord) throws PidNotFoundException, .collect(Collectors.toMap(HandleValue::getIndex, v -> v)); // 1) List valuesToKeep = oldHandleValues.stream() - .filter(HandleProtocolAdapter::isHandleInternalValue) + .filter(HandleBehavior::isHandleInternalValue) .collect(Collectors.toList()); // 2) Merge requested record and things we want to keep. - Map recordNew = handleValuesFrom(preparedRecord, Optional.of(valuesToKeep)) + Map recordNew = HandleBehavior.handleValuesFrom(preparedRecord, Optional.of(valuesToKeep)) .stream() .collect(Collectors.toMap(HandleValue::getIndex, v -> v)); @@ -353,69 +322,6 @@ public Collection resolveAllPidsOfPrefix() throws ExternalServiceExcepti } } - /** - * Convert a `PIDRecord` instance to an array of `HandleValue`s. It is the - * inverse method to `pidRecordFrom`. - * - * @param pidRecord the record containing values to convert / extract. - * @param toMerge an optional list to merge the result with. - * @return HandleValues containing the same key-value pairs as the given record, - * but e.g. without the name. - */ - protected static ArrayList handleValuesFrom( - final PIDRecord pidRecord, - final Optional> toMerge) - { - ArrayList skippingIndices = new ArrayList<>(); - ArrayList result = new ArrayList<>(); - if (toMerge.isPresent()) { - for (HandleValue v : toMerge.get()) { - result.add(v); - skippingIndices.add(v.getIndex()); - } - } - HandleIndex index = new HandleIndex().skipping(skippingIndices); - Map> entries = pidRecord.getEntries(); - - for (Entry> entry : entries.entrySet()) { - for (PIDRecordEntry val : entry.getValue()) { - String key = val.getKey(); - HandleValue hv = new HandleValue(); - int i = index.nextIndex(); - hv.setIndex(i); - hv.setType(key.getBytes(StandardCharsets.UTF_8)); - hv.setData(val.getValue().getBytes(StandardCharsets.UTF_8)); - result.add(hv); - LOG.debug("Entry: ({}) {} <-> {}", i, key, val); - } - } - assert result.size() >= pidRecord.getEntries().size(); - return result; - } - - protected static class HandleIndex { - // handle record indices start at 1 - private int index = 1; - private List skipping = new ArrayList<>(); - - public final int nextIndex() { - int result = index; - do { - index += 1; - } while (index == this.getHsAdminIndex() || skipping.contains(index)); - return result; - } - - public HandleIndex skipping(List skipThose) { - this.skipping = skipThose; - return this; - } - - public final int getHsAdminIndex() { - return 100; - } - } - /** * Returns true if the PID is valid according to the following criteria: * - PID is valid according to isIdentifierRegistered @@ -439,96 +345,4 @@ protected boolean isValidPID(final String pid) { } return true; } - - /** - * Checks if a given value is considered an "internal" or "handle-native" value. - *

- * This may be used to filter out administrative information from a PID record. - * - * @param v the value to check. - * @return true, if the value is conidered "handle-native". - */ - public static boolean isHandleInternalValue(HandleValue v) { - boolean isInternalValue = false; - for (byte[][] typeList : BLACKLIST_NONTYPE_LISTS) { - for (byte[] typeCode : typeList) { - isInternalValue = isInternalValue || Arrays.equals(v.getType(), typeCode); - } - } - return isInternalValue; - } - - /** - * Given two Value Maps, it splits the values in those which have been added, - * updated or removed. - * Using this lists, an update can be applied to the old record, to bring it to - * the state of the new record. - */ - protected static class HandleDiff { - private final Collection toAdd = new ArrayList<>(); - private final Collection toUpdate = new ArrayList<>(); - private final Collection toRemove = new ArrayList<>(); - - HandleDiff( - final Map recordOld, - final Map recordNew - ) throws PidUpdateException { - for (Entry old : recordOld.entrySet()) { - boolean wasRemoved = !recordNew.containsKey(old.getKey()); - if (wasRemoved) { - // if a row in the record is not available anymore, we need to delete it - toRemove.add(old.getValue()); - } else { - // otherwise, we should go and update it. - // we could also check for equality, but this is the safe and easy way. - // (the handlevalue classes can be complicated and we'd have to check their - // equality implementation) - toUpdate.add(recordNew.get(old.getKey())); - } - } - for (Entry e : recordNew.entrySet()) { - boolean isNew = !recordOld.containsKey(e.getKey()); - if (isNew) { - // if there is a record which is not in the oldRecord, we need to add it. - toAdd.add(e.getValue()); - } - } - - // runtime testing to avoid messing up record states. - String exceptionMsg = "DIFF NOT VALID. Type: %s. Value: %s"; - for (HandleValue v : toRemove) { - boolean valid = recordOld.containsValue(v) && !recordNew.containsKey(v.getIndex()); - if (!valid) { - String message = String.format(exceptionMsg, "Remove", v.toString()); - throw new PidUpdateException(message); - } - } - for (HandleValue v : toAdd) { - boolean valid = !recordOld.containsKey(v.getIndex()) && recordNew.containsValue(v); - if (!valid) { - String message = String.format(exceptionMsg, "Add", v); - throw new PidUpdateException(message); - } - } - for (HandleValue v : toUpdate) { - boolean valid = recordOld.containsKey(v.getIndex()) && recordNew.containsValue(v); - if (!valid) { - String message = String.format(exceptionMsg, "Update", v); - throw new PidUpdateException(message); - } - } - } - - public HandleValue[] added() { - return this.toAdd.toArray(new HandleValue[] {}); - } - - public HandleValue[] updated() { - return this.toUpdate.toArray(new HandleValue[] {}); - } - - public HandleValue[] removed() { - return this.toRemove.toArray(new HandleValue[] {}); - } - } } diff --git a/src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java b/src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java index e9a03623..66ac9044 100644 --- a/src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java +++ b/src/main/java/edu/kit/datamanager/pit/resolver/Resolver.java @@ -3,7 +3,7 @@ import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.common.PidNotFoundException; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter; +import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleBehavior; import edu.kit.datamanager.pit.pitservice.ITypingService; import net.handle.api.HSAdapter; import net.handle.api.HSAdapterFactory; @@ -66,9 +66,9 @@ public PIDRecord resolve(String pid) throws PidNotFoundException, ExternalServic } else { try { Collection recordProperties = Arrays.stream(this.client.resolveHandle(pid, null, null)) - .filter(value -> !HandleProtocolAdapter.isHandleInternalValue(value)) + .filter(value -> !HandleBehavior.isHandleInternalValue(value)) .collect(Collectors.toList()); - return new PIDRecord(recordProperties).withPID(pid); + return HandleBehavior.recordFrom(recordProperties).withPID(pid); } catch (HandleException e) { int code = e.getCode(); boolean isExistingPid = code == HandleException.HANDLE_DOES_NOT_EXIST; diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapterTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleDiffTest.java similarity index 91% rename from src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapterTest.java rename to src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleDiffTest.java index 811be6ed..fe8dcf39 100644 --- a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleProtocolAdapterTest.java +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleDiffTest.java @@ -7,10 +7,9 @@ import org.junit.jupiter.api.Test; -import edu.kit.datamanager.pit.pidsystem.impl.handle.HandleProtocolAdapter.HandleDiff; import net.handle.hdllib.HandleValue; -class HandleProtocolAdapterTest { +class HandleDiffTest { @Test void testDiffOldRecordEmpty() { Map oldRecord = new HashMap<>(); @@ -70,11 +69,11 @@ void testDiffOneOfEachChange() { assertEquals(1, diff.added().length); } - private void addSomeHandleValue(Map record, int index) { + private static void addSomeHandleValue(Map record, int index) { record.put(index, getHandleValue(index)); } - private HandleValue getHandleValue(int index) { + private static HandleValue getHandleValue(int index) { return new HandleValue(index, "", ""); } } diff --git a/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleIndexTest.java b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleIndexTest.java new file mode 100644 index 00000000..5151e071 --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/pidsystem/impl/handle/HandleIndexTest.java @@ -0,0 +1,39 @@ +package edu.kit.datamanager.pit.pidsystem.impl.handle; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class HandleIndexTest { + + @Test + void isSkippingDefaultAdminIndex() { + HandleIndex handleIndex = new HandleIndex(); + for (int value = 1; value <= 200; value++) { + if (value >= handleIndex.getHsAdminIndex()) { + assertEquals(value + 1, handleIndex.nextIndex()); + } else { + assertEquals(value, handleIndex.nextIndex()); + } + } + } + + @Test + void isSkippingList() { + List skipping = List.of(3, 10, 42, 1337); + HandleIndex handleIndex = new HandleIndex().skipping(skipping); + + int lastValue = 0; + for (int _i = 1; _i <= 200; _i++) { + int value = handleIndex.nextIndex(); + assertTrue(value != handleIndex.getHsAdminIndex()); + for (int skip : skipping) { + assertNotEquals(value, skip); + } + assertTrue(value > lastValue); + lastValue = value; + } + } +} \ No newline at end of file From 77266b63419b581cd04855f8f968bd12e1dc7fb7 Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Tue, 4 Feb 2025 15:00:24 +0100 Subject: [PATCH 064/108] minor fixes --- config/application-default.properties | 12 +-- .../pit/web/impl/TypingRESTResourceImpl.java | 102 ++++++++++-------- 2 files changed, 61 insertions(+), 53 deletions(-) diff --git a/config/application-default.properties b/config/application-default.properties index 0dc8f9fb..11032517 100644 --- a/config/application-default.properties +++ b/config/application-default.properties @@ -131,11 +131,11 @@ spring.cloud.gateway.proxy.sensitive=content-length # the all properties with 'binding' define from where messages are received, e.g. the # exchange aka. topic and the queue. The routingKeys are defining wich messages are # routed to the aforementioned queue. -repo.messaging.enabled: false -repo.messaging.hostname: localhost -repo.messaging.port: 5672 -repo.messaging.sender.exchange: record_events - +repo.messaging.enabled=false +management.health.rabbit.enabled=false +repo.messaging.hostname=localhost +repo.messaging.port=5672 +repo.messaging.sender.exchange=record_events # The rate in milliseconds at which the repository itself will check for new messages. # E.g. if a resource has been created, the repository may has to perform additional # ingest steps. Therefor, special handlers can be added which will be executed at the @@ -201,7 +201,7 @@ pit.validation.alwaysAllowAdditionalAttributes = true # where a DTR may not be available or an external validator is being # used. # -# pit.validation.strategy = debug-none +# pit.validation.strategy=none-debug ### DANGEROUS OPTIONS! Please read carefully! ######################################## ####################################################### diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index 5557ad90..92db2399 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -1,13 +1,23 @@ +/* + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package edu.kit.datamanager.pit.web.impl; +import edu.kit.datamanager.entities.messaging.PidRecordMessage; import edu.kit.datamanager.exceptions.CustomInternalServerError; -import java.io.IOException; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Optional; -import java.util.stream.Stream; - import edu.kit.datamanager.pit.common.*; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.configuration.PidGenerationProperties; @@ -23,13 +33,10 @@ import edu.kit.datamanager.pit.web.ITypingRestResource; import edu.kit.datamanager.pit.web.TabulatorPaginationFormat; import edu.kit.datamanager.service.IMessagingService; -import edu.kit.datamanager.entities.messaging.PidRecordMessage; import edu.kit.datamanager.util.AuthenticationHelper; import edu.kit.datamanager.util.ControllerUtils; import io.swagger.v3.oas.annotations.media.Schema; - import jakarta.servlet.http.HttpServletResponse; - import org.apache.commons.lang3.stream.Streams; import org.apache.http.client.cache.HeaderConstants; import org.slf4j.Logger; @@ -47,22 +54,24 @@ import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.util.UriComponentsBuilder; +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Stream; + @RestController @RequestMapping(value = "/api/v1/pit") @Schema(description = "PID Information Types API") public class TypingRESTResourceImpl implements ITypingRestResource { private static final Logger LOG = LoggerFactory.getLogger(TypingRESTResourceImpl.class); - - @Autowired - private ApplicationProperties applicationProps; - @Autowired protected ITypingService typingService; - @Autowired protected Resolver resolver; - + @Autowired + private ApplicationProperties applicationProps; @Autowired private IMessagingService messagingService; @@ -81,8 +90,8 @@ public class TypingRESTResourceImpl implements ITypingRestResource { public TypingRESTResourceImpl() { super(); } - - @Override + + @Override public ResponseEntity> createPIDs( List rec, boolean dryrun, @@ -114,7 +123,7 @@ public ResponseEntity> createPIDs( // register the records validatedRecords.forEach(pidRecord -> { // register the PID - String pid = this.typingService.registerPID(pidRecord); + String pid = this.typingService.registerPid(pidRecord); pidRecord.setPid(pid); // store pid locally in accordance with the storage strategy @@ -137,6 +146,7 @@ public ResponseEntity> createPIDs( // save the record to elastic this.saveToElastic(pidRecord); }); + Instant endTime = Instant.now(); // return the created records @@ -304,10 +314,10 @@ public ResponseEntity updatePID( String pidInternal = pidRecord.getPid(); if (hasPid(pidRecord) && !pid.equals(pidInternal)) { throw new RecordValidationException( - pidRecord, - "Optional PID in record is given (%s), but it was not the same as the PID in the URL (%s). Ignore request, assuming this was not intended.".formatted(pidInternal, pid)); + pidRecord, + "Optional PID in record is given (%s), but it was not the same as the PID in the URL (%s). Ignore request, assuming this was not intended.".formatted(pidInternal, pid)); } - + PIDRecord existingRecord = this.resolver.resolve(pid); if (existingRecord == null) { throw new PidNotFoundException(pid); @@ -347,7 +357,7 @@ public ResponseEntity updatePID( /** * Stores the PID in a local database. - * + * * @param pid the PID * @param update if true, updates the modified timestamp if it already exists. * If it does not exist, it will be created with both timestamps @@ -396,9 +406,9 @@ public ResponseEntity getRecord( private void saveToElastic(PIDRecord rec) { this.elastic.ifPresent( - database -> database.save( - new PidRecordElasticWrapper(rec, typingService.getOperations()) - ) + database -> database.save( + new PidRecordElasticWrapper(rec, typingService.getOperations()) + ) ); } @@ -417,11 +427,11 @@ public ResponseEntity findByPid( } public Page findAllPage( - Instant createdAfter, - Instant createdBefore, - Instant modifiedAfter, - Instant modifiedBefore, - Pageable pageable + Instant createdAfter, + Instant createdBefore, + Instant modifiedAfter, + Instant modifiedBefore, + Pageable pageable ) { final boolean queriesCreated = createdAfter != null || createdBefore != null; final boolean queriesModified = modifiedAfter != null || modifiedBefore != null; @@ -442,11 +452,11 @@ public Page findAllPage( Page resultModifiedTimestamp = Page.empty(); if (queriesCreated) { resultCreatedTimestamp = this.localPidStorage - .findDistinctPidsByCreatedBetween(createdAfter, createdBefore, pageable); + .findDistinctPidsByCreatedBetween(createdAfter, createdBefore, pageable); } if (queriesModified) { resultModifiedTimestamp = this.localPidStorage - .findDistinctPidsByModifiedBetween(modifiedAfter, modifiedBefore, pageable); + .findDistinctPidsByModifiedBetween(modifiedAfter, modifiedBefore, pageable); } if (queriesCreated && queriesModified) { final Page tmp = resultModifiedTimestamp; @@ -469,15 +479,14 @@ public ResponseEntity> findAll( Pageable pageable, WebRequest request, HttpServletResponse response, - UriComponentsBuilder uriBuilder) throws IOException - { + UriComponentsBuilder uriBuilder) throws IOException { Page page = this.findAllPage(createdAfter, createdBefore, modifiedAfter, modifiedBefore, pageable); response.addHeader( - HeaderConstants.CONTENT_RANGE, - ControllerUtils.getContentRangeHeader( - page.getNumber(), - page.getSize(), - page.getTotalElements())); + HeaderConstants.CONTENT_RANGE, + ControllerUtils.getContentRangeHeader( + page.getNumber(), + page.getSize(), + page.getTotalElements())); return ResponseEntity.ok().body(page.getContent()); } @@ -490,15 +499,14 @@ public ResponseEntity> findAllForTabular( Pageable pageable, WebRequest request, HttpServletResponse response, - UriComponentsBuilder uriBuilder) throws IOException - { + UriComponentsBuilder uriBuilder) throws IOException { Page page = this.findAllPage(createdAfter, createdBefore, modifiedAfter, modifiedBefore, pageable); response.addHeader( - HeaderConstants.CONTENT_RANGE, - ControllerUtils.getContentRangeHeader( - page.getNumber(), - page.getSize(), - page.getTotalElements())); + HeaderConstants.CONTENT_RANGE, + ControllerUtils.getContentRangeHeader( + page.getNumber(), + page.getSize(), + page.getTotalElements())); TabulatorPaginationFormat tabPage = new TabulatorPaginationFormat<>(page); return ResponseEntity.ok().body(tabPage); } From 8f2a29bc11130ffd8a286b093dcaa74d9a91dca8 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 12 Feb 2025 17:21:12 +0100 Subject: [PATCH 065/108] chore: switch to other json schema validator --- build.gradle | 3 +- .../datamanager/pit/domain/Operations.java | 11 +++-- .../pit/typeregistry/AttributeInfo.java | 25 ++++++----- .../schema/DtrTestSchemaGenerator.java | 28 ++++++++----- .../pit/typeregistry/schema/SchemaInfo.java | 6 +-- .../schema/TypeApiSchemaGenerator.java | 28 ++++++++----- .../schema/SchemaSetGeneratorTest.java | 42 +++++++++++++------ 7 files changed, 87 insertions(+), 56 deletions(-) diff --git a/build.gradle b/build.gradle index 06a36087..5bedf90e 100644 --- a/build.gradle +++ b/build.gradle @@ -81,8 +81,7 @@ dependencies { implementation "org.springframework.data:spring-data-elasticsearch" // More flexibility when (de-)serializing json: - //implementation("com.monitorjbl:spring-json-view:1.1.0") - implementation("com.github.everit-org.json-schema:org.everit.json.schema:1.14.4") + implementation(group: 'com.networknt', name: 'json-schema-validator', version: '1.5.5'); implementation('org.apache.httpcomponents:httpclient:4.5.14') implementation('org.apache.httpcomponents:httpclient-cache:4.5.14') diff --git a/src/main/java/edu/kit/datamanager/pit/domain/Operations.java b/src/main/java/edu/kit/datamanager/pit/domain/Operations.java index 95246d83..8b86af01 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/Operations.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/Operations.java @@ -1,6 +1,7 @@ package edu.kit.datamanager.pit.domain; import java.io.IOException; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -15,9 +16,7 @@ import edu.kit.datamanager.pit.typeregistry.AttributeInfo; import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import org.apache.commons.lang3.stream.Streams; -import org.joda.time.DateTime; -import org.joda.time.format.DateTimeFormatter; -import org.joda.time.format.ISODateTimeFormat; +import java.time.ZonedDateTime; /** * Simple operations on PID records. @@ -181,10 +180,10 @@ public Optional findDateModified(PIDRecord pidRecord) throws IOException { * @return the extracted Date object. */ protected Optional extractDate(String dateString) { - DateTimeFormatter dateFormatter = ISODateTimeFormat.dateTime(); + DateTimeFormatter dateFormatter = DateTimeFormatter.ISO_DATE_TIME; try { - DateTime dateTime = dateFormatter.parseDateTime(dateString); - return Optional.of(dateTime.toDate()); + ZonedDateTime dateTime = ZonedDateTime.parse(dateString, dateFormatter); + return Optional.of(Date.from(dateTime.toInstant())); } catch (Exception e) { return Optional.empty(); } diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java index 9e4b1a27..2a880dd0 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java @@ -1,12 +1,14 @@ package edu.kit.datamanager.pit.typeregistry; +import com.fasterxml.jackson.databind.JsonNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.ValidationMessage; +import edu.kit.datamanager.pit.Application; import edu.kit.datamanager.pit.typeregistry.schema.SchemaInfo; -import org.everit.json.schema.Schema; -import org.everit.json.schema.ValidationException; -import org.json.JSONObject; import java.util.Collection; import java.util.Objects; +import java.util.Set; /** * @param pid the pid of this attribute @@ -29,16 +31,17 @@ public boolean validate(String value) { .anyMatch(schema -> validate(schema, value)); } - private boolean validate(Schema schema, String value) { - Object toValidate = value; - if (value.startsWith("{")) { - toValidate = new JSONObject(value); - } + private boolean validate(JsonSchema schema, String value) { try { - schema.validate(toValidate); - } catch (ValidationException e) { + JsonNode toValidate = Application.jsonObjectMapper().readTree(value); + Set errors = schema.validate(toValidate, executionContext -> { + // By default since Draft 2019-09 the format keyword only generates annotations and not assertions + executionContext.getExecutionConfig().setFormatAssertionsEnabled(true); + }); + return errors.isEmpty(); + // TODO we could catch the validation errors here in order to return them to the user + } catch (Exception e) { return false; } - return true; } } diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java index c01727ec..3415d8be 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java @@ -1,16 +1,15 @@ package edu.kit.datamanager.pit.typeregistry.schema; +import com.fasterxml.jackson.databind.JsonNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; import edu.kit.datamanager.pit.Application; import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.common.InvalidConfigException; import edu.kit.datamanager.pit.common.TypeNotFoundException; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import jakarta.validation.constraints.NotNull; -import org.everit.json.schema.Schema; -import org.everit.json.schema.loader.SchemaLoader; -import org.json.JSONException; -import org.json.JSONObject; -import org.json.JSONTokener; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatusCode; @@ -18,6 +17,7 @@ import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; +import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; @@ -29,6 +29,7 @@ public class DtrTestSchemaGenerator implements SchemaGenerator { protected static final String ORIGIN = "dtr-test"; protected final URI baseUrl; protected final RestClient http; + JsonSchemaFactory schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); public DtrTestSchemaGenerator(@NotNull ApplicationProperties props) { try { @@ -61,16 +62,21 @@ public SchemaInfo generateSchema(@NotNull String maybeTypePid) { .exchange((request, response) -> { HttpStatusCode status = response.getStatusCode(); if (status.is2xxSuccessful()) { - Schema schema = null; + JsonSchema schema = null; try (InputStream inputStream = response.getBody()) { - JSONObject jsonBody = new JSONObject(new JSONTokener(inputStream)); - JSONObject rawSchema = new JSONObject(new JSONTokener(jsonBody.getString("validationSchema"))); - schema = SchemaLoader.load(rawSchema); - } catch (JSONException e) { + JsonNode schemaDocument = Application.jsonObjectMapper() + .readTree(inputStream) + .path("validationSchema"); + schema = this.schemaFactory.getSchema(schemaDocument); + if (schema == null || schema.getSchemaNode().isEmpty()) { + throw new IOException("Could not create valid schema for %s from %s " + .formatted(maybeTypePid, schemaDocument)); + } + } catch (IOException e) { return new SchemaInfo( ORIGIN, schema, - new ExternalServiceException(baseUrl.toString(), "No valid schema found resolving PID " + maybeTypePid) + new ExternalServiceException(baseUrl.toString(), "No valid schema found resolving PID " + maybeTypePid, e) ); } return new SchemaInfo(ORIGIN, schema, null); diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java index d6143c40..5330e1d5 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java @@ -1,21 +1,21 @@ package edu.kit.datamanager.pit.typeregistry.schema; +import com.networknt.schema.JsonSchema; import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; -import org.everit.json.schema.Schema; import java.util.Optional; public record SchemaInfo( @NotNull String origin, - @Nullable Schema schema, + @Nullable JsonSchema schema, @Nullable Throwable error ) { Optional hasError() { return Optional.ofNullable(this.error); } - Optional hasSchema() { + Optional hasSchema() { return Optional.ofNullable(this.schema); } } diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java index 7555a7a4..4dd88c58 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java @@ -1,22 +1,22 @@ package edu.kit.datamanager.pit.typeregistry.schema; +import com.fasterxml.jackson.databind.JsonNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; import edu.kit.datamanager.pit.Application; import edu.kit.datamanager.pit.common.ExternalServiceException; import edu.kit.datamanager.pit.common.InvalidConfigException; import edu.kit.datamanager.pit.common.TypeNotFoundException; import edu.kit.datamanager.pit.configuration.ApplicationProperties; import jakarta.validation.constraints.NotNull; -import org.everit.json.schema.Schema; -import org.everit.json.schema.loader.SchemaLoader; -import org.json.JSONException; -import org.json.JSONObject; -import org.json.JSONTokener; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatusCode; import org.springframework.http.client.ClientHttpResponse; import org.springframework.web.client.RestClient; +import java.io.IOException; import java.io.InputStream; import java.net.URISyntaxException; import java.net.URL; @@ -26,6 +26,7 @@ public class TypeApiSchemaGenerator implements SchemaGenerator { protected final URL baseUrl; protected final RestClient http; + JsonSchemaFactory schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); public TypeApiSchemaGenerator(@NotNull ApplicationProperties props) { this.baseUrl = props.getTypeRegistryUri(); @@ -60,15 +61,22 @@ public SchemaInfo generateSchema(@NotNull String maybeTypePid) { .exchange((request, response) -> { HttpStatusCode statusCode = response.getStatusCode(); if (statusCode.is2xxSuccessful()) { - Schema schema = null; + JsonSchema schema = null; try (InputStream inputStream = response.getBody()) { - JSONObject rawSchema = new JSONObject(new JSONTokener(inputStream)); - schema = SchemaLoader.load(rawSchema); - } catch (JSONException e) { + JsonNode schemaDocument = Application.jsonObjectMapper() + .readTree(inputStream); + schema = schemaFactory.getSchema(schemaDocument); + if (schema == null || schema.getSchemaNode().isEmpty()) { + throw new IOException("Could not create valid schema for %s from %s " + .formatted(maybeTypePid, schemaDocument)); + } + } catch (IOException e) { return new SchemaInfo( this.baseUrl.toString(), schema, - new ExternalServiceException(baseUrl.toString(), "Response (" + maybeTypePid + ") is not a valid schema.") + new ExternalServiceException( + baseUrl.toString(), + "Response (" + maybeTypePid + ") is not a valid schema.") ); } return new SchemaInfo(this.baseUrl.toString(), schema, null); diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java index f29b1f85..dc41d348 100644 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java @@ -2,13 +2,17 @@ import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.typeregistry.AttributeInfo; -import org.everit.json.schema.ValidationException; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; import java.net.URI; import java.util.NoSuchElementException; import java.util.Set; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; @@ -27,17 +31,29 @@ static void setup() throws Exception { generator = new SchemaSetGenerator(properties); } - /** - * @throws ValidationException if a schema fails to validate - * @throws NoSuchElementException if no schema is found - */ - @Test - void testChecksumValidation() throws ValidationException, NoSuchElementException { - // generated test for this error message: Reason:\nAttribute 21.T11148/92e200311a56800b3e47 has a non-complying value { \"sha256sum\": \"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\" } - Set schemaInfos = generator.generateFor("21.T11148/92e200311a56800b3e47").join(); - AttributeInfo attributeInfo = new AttributeInfo("21.T11148/92e200311a56800b3e47", "name", "typeName", schemaInfos); - assertTrue(attributeInfo.validate("{ \"sha256sum\": \"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\" }")); - // This is currently not supported, but would be nice to have: - assertFalse(attributeInfo.validate("\"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\"")); + private static Stream typeWithExamplesAndCounterexamples() { + return Stream.of( // typePid, example, counterexample + // checksum + Arguments.of( + "21.T11148/92e200311a56800b3e47", + "{ \"sha256sum\": \"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\" }", + "\"c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\""), + // checksum + Arguments.of( + "21.T11148/92e200311a56800b3e47", + "\"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\"", + "\"not a checksum\""), + // URI with schema making use of "format" to specify a uri + Arguments.of("21.T11969/cb371c93c5aa0e62198e", "\"https://example.com\"", "This is not a URI") + ); + } + + @ParameterizedTest + @MethodSource("typeWithExamplesAndCounterexamples") + void testExampleAndCounterexample(String typePid, String example, String counterexample) { + Set schemaInfos = generator.generateFor(typePid).join(); + AttributeInfo attributeInfo = new AttributeInfo(typePid, "name", "typeName", schemaInfos); + assertTrue(attributeInfo.validate(example)); + assertFalse(attributeInfo.validate(counterexample)); } } \ No newline at end of file From dcb1605c2c5c137e2a974937b680d848f09d368d Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Thu, 13 Feb 2025 00:56:51 +0100 Subject: [PATCH 066/108] cleanup: apply linter suggestions --- .../datamanager/pit/domain/Operations.java | 126 ++++++++---------- .../pit/typeregistry/AttributeInfo.java | 2 +- .../pit/typeregistry/schema/SchemaInfo.java | 12 +- .../schema/SchemaSetGeneratorTest.java | 5 +- 4 files changed, 62 insertions(+), 83 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/domain/Operations.java b/src/main/java/edu/kit/datamanager/pit/domain/Operations.java index 8b86af01..baa688ed 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/Operations.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/Operations.java @@ -20,7 +20,7 @@ /** * Simple operations on PID records. - * + *

* Caches results e.g. for type queries */ public class Operations { @@ -34,8 +34,8 @@ public class Operations { "21.T11148/397d831aa3a9d18eb52c" }; - private ITypeRegistry typeRegistry; - private IIdentifierSystem identifierSystem; + private final ITypeRegistry typeRegistry; + private final IIdentifierSystem identifierSystem; public Operations(ITypeRegistry typeRegistry, IIdentifierSystem identifierSystem) { this.typeRegistry = typeRegistry; @@ -44,30 +44,29 @@ public Operations(ITypeRegistry typeRegistry, IIdentifierSystem identifierSystem /** * Tries to get the date when a FAIR DO was created from a PID record. - * + *

* Strategy: * - try to get it from known "dateCreated" types - * - as a fallback, try to get it by its human readable name - * + * - as a fallback, try to get it by its human-readable name + *

* Semantic reasoning in some sense is planned but not yet supported. * * @param pidRecord the record to extract the information from. - * @return the date, if it could been extracted. + * @return the date, if it could have been extracted. * @throws IOException on IO errors regarding resolving types. */ public Optional findDateCreated(PIDRecord pidRecord) throws IOException { /* try known types */ List knownDateTypes = Arrays.asList(Operations.KNOWN_DATE_CREATED); Optional date = knownDateTypes - .stream() - .map(pidRecord::getPropertyValues) - .map(Arrays::asList) - .flatMap(List::stream) - .map(this::extractDate) - .filter(Optional::isPresent) - .map(Optional::get) - .sorted(Comparator.comparingLong(Date::getTime)) - .findFirst(); + .stream() + .map(pidRecord::getPropertyValues) + .map(Arrays::asList) + .flatMap(List::stream) + .map(this::extractDate) + .filter(Optional::isPresent) + .map(Optional::get) + .min(Comparator.comparingLong(Date::getTime)); if (date.isPresent()) { return date; } @@ -75,17 +74,15 @@ public Optional findDateCreated(PIDRecord pidRecord) throws IOException { Collection types = new ArrayList<>(); List> futures = Streams.failableStream( pidRecord.getPropertyIdentifiers().stream()) - .filter(attributePid -> this.identifierSystem.isPidRegistered(attributePid)) - .map(attributePid -> { - return this.typeRegistry + .filter(this.identifierSystem::isPidRegistered) + .map(attributePid -> this.typeRegistry .queryAttributeInfo(attributePid) - .thenAcceptAsync(types::add); - }) + .thenAcceptAsync(types::add)) .collect(Collectors.toList()); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); /* - * as a last fallback, try find types with human readable names containing + * as a last fallback, try to find types with human-readable names containing * "dateCreated" or "createdAt" or "creationDate". * * This can be removed as soon as we have some default FAIR DO types new type @@ -93,64 +90,60 @@ public Optional findDateCreated(PIDRecord pidRecord) throws IOException { * our known types, see above) */ return types - .stream() - .filter(type -> - type.name().equalsIgnoreCase("dateCreated") - || type.name().equalsIgnoreCase("createdAt") - || type.name().equalsIgnoreCase("creationDate")) - .map(type -> pidRecord.getPropertyValues(type.pid())) - .map(Arrays::asList) - .flatMap(List::stream) - .map(this::extractDate) - .filter(Optional::isPresent) - .map(Optional::get) - .sorted(Comparator.comparingLong(Date::getTime)) - .findFirst(); + .stream() + .filter(type -> + type.name().equalsIgnoreCase("dateCreated") + || type.name().equalsIgnoreCase("createdAt") + || type.name().equalsIgnoreCase("creationDate")) + .map(type -> pidRecord.getPropertyValues(type.pid())) + .map(Arrays::asList) + .flatMap(List::stream) + .map(this::extractDate) + .filter(Optional::isPresent) + .map(Optional::get) + .min(Comparator.comparingLong(Date::getTime)); } /** * Tries to get the date when a FAIR DO was modified from a PID record. - * + *

* Strategy: * - try to get it from known "dateModified" types - * - as a fallback, try to get it by its human readable name - * + * - as a fallback, try to get it by its human-readable name + *

* Semantic reasoning in some sense is planned but not yet supported. * * @param pidRecord the record to extract the information from. - * @return the date, if it could been extracted. + * @return the date, if it could have been extracted. * @throws IOException on IO errors regarding resolving types. */ public Optional findDateModified(PIDRecord pidRecord) throws IOException { /* try known types */ List knownDateTypes = Arrays.asList(Operations.KNOWN_DATE_MODIFIED); Optional date = knownDateTypes - .stream() - .map(pidRecord::getPropertyValues) - .map(Arrays::asList) - .flatMap(List::stream) - .map(this::extractDate) - .filter(Optional::isPresent) - .map(Optional::get) - .sorted(Comparator.comparingLong(Date::getTime)) - .findFirst(); + .stream() + .map(pidRecord::getPropertyValues) + .map(Arrays::asList) + .flatMap(List::stream) + .map(this::extractDate) + .filter(Optional::isPresent) + .map(Optional::get) + .min(Comparator.comparingLong(Date::getTime)); if (date.isPresent()) { return date; } Collection types = new ArrayList<>(); List> futures = Streams.failableStream(pidRecord.getPropertyIdentifiers().stream()) - .filter(attributePid -> this.identifierSystem.isPidRegistered(attributePid)) - .map(attributePid -> { - return this.typeRegistry + .filter(this.identifierSystem::isPidRegistered) + .map(attributePid -> this.typeRegistry .queryAttributeInfo(attributePid) - .thenAcceptAsync(types::add); - }) + .thenAcceptAsync(types::add)) .collect(Collectors.toList()); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); /* - * as a last fallback, try find types with human readable names containing + * as a last fallback, try to find types with human-readable names containing * "dateModified" or "lastModified" or "modificationDate". * * This can be removed as soon as we have some default FAIR DO types new type @@ -158,19 +151,18 @@ public Optional findDateModified(PIDRecord pidRecord) throws IOException { * our known types, see above) */ return types - .stream() - .filter(type -> - type.name().equalsIgnoreCase("dateModified") - || type.name().equalsIgnoreCase("lastModified") - || type.name().equalsIgnoreCase("modificationDate")) - .map(type -> pidRecord.getPropertyValues(type.pid())) - .map(Arrays::asList) - .flatMap(List::stream) - .map(this::extractDate) - .filter(Optional::isPresent) - .map(Optional::get) - .sorted(Comparator.comparingLong(Date::getTime)) - .findFirst(); + .stream() + .filter(type -> + type.name().equalsIgnoreCase("dateModified") + || type.name().equalsIgnoreCase("lastModified") + || type.name().equalsIgnoreCase("modificationDate")) + .map(type -> pidRecord.getPropertyValues(type.pid())) + .map(Arrays::asList) + .flatMap(List::stream) + .map(this::extractDate) + .filter(Optional::isPresent) + .map(Optional::get) + .min(Comparator.comparingLong(Date::getTime)); } /** diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java index 2a880dd0..4e4a3527 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java @@ -35,7 +35,7 @@ private boolean validate(JsonSchema schema, String value) { try { JsonNode toValidate = Application.jsonObjectMapper().readTree(value); Set errors = schema.validate(toValidate, executionContext -> { - // By default since Draft 2019-09 the format keyword only generates annotations and not assertions + // By default, since Draft 2019-09, the format keyword only generates annotations and not assertions executionContext.getExecutionConfig().setFormatAssertionsEnabled(true); }); return errors.isEmpty(); diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java index 5330e1d5..ae96712e 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaInfo.java @@ -4,18 +4,8 @@ import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; -import java.util.Optional; - public record SchemaInfo( @NotNull String origin, @Nullable JsonSchema schema, @Nullable Throwable error -) { - Optional hasError() { - return Optional.ofNullable(this.error); - } - - Optional hasSchema() { - return Optional.ofNullable(this.schema); - } -} +) {} diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java index dc41d348..7322f36d 100644 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java @@ -3,14 +3,11 @@ import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.typeregistry.AttributeInfo; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.MethodSource; import java.net.URI; -import java.util.NoSuchElementException; import java.util.Set; import java.util.stream.Stream; @@ -43,7 +40,7 @@ private static Stream typeWithExamplesAndCounterexamples() { "21.T11148/92e200311a56800b3e47", "\"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\"", "\"not a checksum\""), - // URI with schema making use of "format" to specify a uri + // URI with schema making use of "format" to specify an uri Arguments.of("21.T11969/cb371c93c5aa0e62198e", "\"https://example.com\"", "This is not a URI") ); } From 80144643b389e00948b544c058d5420117bab487 Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Tue, 18 Feb 2025 10:04:54 +0100 Subject: [PATCH 067/108] added some error handling mechanisms --- .gitignore | 6 +- .../pit/web/impl/TypingRESTResourceImpl.java | 66 ++++++++++++------- 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index e1814ee1..c9f1da77 100644 --- a/.gitignore +++ b/.gitignore @@ -135,4 +135,8 @@ $RECYCLE.BIN/ **/*.properties !/config/application-default.properties !/config/application-docker.properties -############## \ No newline at end of file +############## + +test_prefix_data/ +config/*.bin +config/*.pem \ No newline at end of file diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index 92db2399..d67c2975 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -120,31 +120,38 @@ public ResponseEntity> createPIDs( return ResponseEntity.status(HttpStatus.OK).body(validatedRecords); } + List failedRecords = new ArrayList<>(); // register the records validatedRecords.forEach(pidRecord -> { - // register the PID - String pid = this.typingService.registerPid(pidRecord); - pidRecord.setPid(pid); - - // store pid locally in accordance with the storage strategy - if (applicationProps.getStorageStrategy().storesModified()) { - storeLocally(pid, true); - } - - // distribute pid creation event to other services - PidRecordMessage message = PidRecordMessage.creation( - pid, - "", // TODO parameter is deprecated and will be removed soon. - AuthenticationHelper.getPrincipal(), - ControllerUtils.getLocalHostname()); try { - this.messagingService.send(message); + // register the PID + String pid = this.typingService.registerPid(pidRecord); + pidRecord.setPid(pid); + + // store pid locally in accordance with the storage strategy + if (applicationProps.getStorageStrategy().storesModified()) { + storeLocally(pid, true); + } + + // distribute pid creation event to other services + PidRecordMessage message = PidRecordMessage.creation( + pid, + "", // TODO parameter is deprecated and will be removed soon. + AuthenticationHelper.getPrincipal(), + ControllerUtils.getLocalHostname()); + try { + this.messagingService.send(message); + } catch (Exception e) { + LOG.error("Could not notify messaging service about the following message: {}", message); + } + + // save the record to elastic + this.saveToElastic(pidRecord); } catch (Exception e) { - LOG.error("Could not notify messaging service about the following message: {}", message); + LOG.error("Could not register PID for record {}. Error: {}", pidRecord, e.getMessage()); + failedRecords.add(pidRecord); + validatedRecords.remove(pidRecord); } - - // save the record to elastic - this.saveToElastic(pidRecord); }); Instant endTime = Instant.now(); @@ -154,8 +161,23 @@ public ResponseEntity> createPIDs( LOG.info("-- Time taken for mapping: {} ms", ChronoUnit.MILLIS.between(startTime, mappingTime)); LOG.info("-- Time taken for validation: {} ms", ChronoUnit.MILLIS.between(mappingTime, validationTime)); LOG.info("-- Time taken for registration: {} ms", ChronoUnit.MILLIS.between(validationTime, endTime)); - LOG.info("Creation finished. Returning validated records for {} records.", validatedRecords.size()); - return ResponseEntity.status(HttpStatus.CREATED).body(validatedRecords); + + if (!failedRecords.isEmpty()) { + for (PIDRecord successfulRecord : validatedRecords) { // rollback the successful records + try { + LOG.debug("Rolling back PID creation for record with PID {}.", successfulRecord.getPid()); + this.typingService.deletePid(successfulRecord.getPid()); + } catch (Exception e) { + LOG.error("Could not rollback PID creation for record with PID {}. Error: {}", successfulRecord.getPid(), e.getMessage()); + } + } + + LOG.info("Creation finished. Returning validated records for {} records. {} records failed to be created.", validatedRecords.size(), failedRecords.size()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(failedRecords); + } else { + LOG.info("Creation finished. Returning validated records for {} records.", validatedRecords.size()); + return ResponseEntity.status(HttpStatus.CREATED).body(validatedRecords); + } } /** From 3b035e6435b150659a45c38acb56e28c9b2116a0 Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Tue, 18 Feb 2025 10:10:28 +0100 Subject: [PATCH 068/108] fixed typo --- config/application-default.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/application-default.properties b/config/application-default.properties index 11032517..1ee66713 100644 --- a/config/application-default.properties +++ b/config/application-default.properties @@ -138,7 +138,7 @@ repo.messaging.port=5672 repo.messaging.sender.exchange=record_events # The rate in milliseconds at which the repository itself will check for new messages. # E.g. if a resource has been created, the repository may has to perform additional -# ingest steps. Therefor, special handlers can be added which will be executed at the +# ingest steps. Therefore, special handlers can be added which will be executed at the # configured repo.schedule.rate if a new message has been received. repo.schedule.rate:1000 From aeb8a8d2f169eedf22939ac2ff1f4739873a1e3b Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Tue, 18 Feb 2025 10:18:25 +0100 Subject: [PATCH 069/108] fixed typo that stopped the tests from working --- .../pit/web/ITypingRestResource.java | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java index 41e7053c..bce03371 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java +++ b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Karlsruhe Institute of Technology. + * Copyright (c) 2024-2025 Karlsruhe Institute of Technology. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -122,21 +122,21 @@ ResponseEntity> createPIDs( * @throws IOException */ @PostMapping( - path = "pid", + path = "pid/", consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} ) @Operation( - summary = "Create a new PID record", - description = "Create a new PID record using the record information from the request body." + - " The record may contain the identifier(s) of the matching profile(s)." + - " Before creating the record, the record information will be validated against" + - " the profile." + - " Validation takes some time, depending on the context. It depends a lot on the size" + - " of your record and the already cached information. This information is gathered" + - " from external services. If there are connection issues or hickups at these sites," + - " validation may even take up to a few seconds. Usually you can expect the request" + - " to be between 100ms up to 1000ms on a fast machine with reliable connections." + summary = "Create a new PID record", + description = "Create a new PID record using the record information from the request body." + + " The record may contain the identifier(s) of the matching profile(s)." + + " Before creating the record, the record information will be validated against" + + " the profile." + + " Validation takes some time, depending on the context. It depends a lot on the size" + + " of your record and the already cached information. This information is gathered" + + " from external services. If there are connection issues or hickups at these sites," + + " validation may even take up to a few seconds. Usually you can expect the request" + + " to be between 100ms up to 1000ms on a fast machine with reliable connections." ) @io.swagger.v3.oas.annotations.parameters.RequestBody( description = "The body containing all PID record values as they should be in the new PIDs record.", @@ -161,9 +161,8 @@ ResponseEntity> createPIDs( @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) }) - public ResponseEntity createPID( - @RequestBody - final PIDRecord rec, + ResponseEntity createPID( + @RequestBody final PIDRecord rec, @Parameter( description = "If true, only validation will be done" + @@ -197,12 +196,12 @@ public ResponseEntity createPID( produces = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE} ) @Operation( - summary = "Update an existing PID record", - description = "Update an existing PID record using the record information from the request body." + - " The record may contain the identifier(s) of the matching profiles. Conditions for a" + - " valid record are the same as for creation." + - " Important note: Validation may take some time. For details, see the documentation of" + - " \"POST /pid/\"." + summary = "Update an existing PID record", + description = "Update an existing PID record using the record information from the request body." + + " The record may contain the identifier(s) of the matching profiles. Conditions for a" + + " valid record are the same as for creation." + + " Important note: Validation may take some time. For details, see the documentation of" + + " \"POST /pid/\"." ) @io.swagger.v3.oas.annotations.parameters.RequestBody( description = "The body containing all PID record values as they should be after the update.", @@ -270,8 +269,7 @@ ResponseEntity updatePID( @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) }) - - public ResponseEntity getRecord ( + ResponseEntity getRecord( @Parameter( description = "If true, validation will be run on the" + " resolved PID. On failure, an error will be" + From b3662a1f637ad508751086fdeb2da2f0a84aa36e Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Wed, 19 Feb 2025 13:34:17 +0100 Subject: [PATCH 070/108] added builder for PIDRecords and PIDs for testing Signed-off-by: Maximilian Inckmann --- .../kit/datamanager/pit/web/PIDBuilder.java | 146 +++++++++++++++ .../datamanager/pit/web/PIDRecordBuilder.java | 168 ++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java create mode 100644 src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java diff --git a/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java b/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java new file mode 100644 index 00000000..a72c6d19 --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.kit.datamanager.pit.web; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Random; +import java.util.UUID; + +public class PIDBuilder { + Long seed; + private Random random; + private String prefix; + private String suffix; + + public PIDBuilder() { + this(new Random().nextLong()); + } + + public PIDBuilder(Long seed) { + this.seed = seed; + this.random = new Random(seed); + + // Default values + this.validPrefix(); + this.validSuffix(); + } + + private static UUID generateUUID(String seed) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + byte[] hash = md.digest(seed.getBytes(StandardCharsets.UTF_8)); + long msb = 0; + long lsb = 0; + for (int i = 0; i < 8; i++) { + msb = (msb << 8) | (hash[i] & 0xff); + } + for (int i = 8; i < 16; i++) { + lsb = (lsb << 8) | (hash[i] & 0xff); + } + return new UUID(msb, lsb); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + public PIDBuilder withSeed(Long seed) { + this.seed = seed; + this.random = new Random(seed); + return this; + } + + public PIDBuilder withPrefix(String prefix) { + this.prefix = prefix; + return this; + } + + public String build() { + return prefix + "/" + suffix; + } + + public PIDBuilder clone(PIDBuilder builder) { + this.seed = builder.seed; + this.random = new Random(seed); + this.prefix = builder.prefix; + this.suffix = builder.suffix; + return this; + } + + public PIDBuilder validPrefix() { + this.prefix = "sandboxed"; + return this; + } + + public PIDBuilder unauthorizedPrefix() { + this.prefix = "0.NA"; + return this; + } + + public PIDBuilder emptyPrefix() { + this.prefix = ""; + return this; + } + + public PIDBuilder invalidCharactersPrefix() { + // generate a random String not fulfilling this regex: ^[a-zA-Z0-9.-]+$ + StringBuilder result = new StringBuilder(); + for (int i = 0; i < random.nextInt(256); i++) { // Random length + // generate a random character that is not a letter, number, dot or hyphen + char c; + do { + c = (char) random.nextInt(Character.MAX_VALUE); // Random character + } while (Character.isLetterOrDigit(c) || c == '.' || c == '-'); // Continue until an invalid character is found + result.append(c); + } + this.prefix = result.toString(); + return this; + } + + public PIDBuilder withSuffix(String suffix) { + this.suffix = suffix; + return this; + } + + public PIDBuilder validSuffix() { + // generate a UUID based on the seed + UUID uuid = generateUUID(seed.toString()); + this.suffix = uuid.toString(); + return this; + } + + public PIDBuilder emptySuffix() { + this.suffix = ""; + return this; + } + + public PIDBuilder invalidCharactersSuffix() { + // generate a random String not fulfilling this regex: ^[a-f0-9-]+$ + StringBuilder result = new StringBuilder(); + for (int i = 0; i < random.nextInt(256); i++) { // Random length + // generate a random character that is not a letter, number or hyphen + char c; + do { + c = (char) random.nextInt(Character.MAX_VALUE); // Random character + } while (Character.digit(c, 16) == -1 && c != '-'); // Continue until an invalid character is found + result.append(c); + } + this.suffix = result.toString(); + return this; + } +} diff --git a/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java b/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java new file mode 100644 index 00000000..762d18b0 --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.kit.datamanager.pit.web; + +import edu.kit.datamanager.pit.domain.PIDRecord; +import edu.kit.datamanager.pit.domain.PIDRecordEntry; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.*; + +public class PIDRecordBuilder { + private static final Instant NOW = Instant.now().plus(1, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS); + private static final Instant YESTERDAY = NOW.minus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); + private static final Instant TOMORROW = NOW.plus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); + + Long seed; + private Random random; + private PIDRecord record; + + public PIDRecordBuilder() { + this(null); + } + + public PIDRecordBuilder(PIDBuilder pidBuilder) { + this(pidBuilder, new Random().nextLong()); + } + + public PIDRecordBuilder(PIDBuilder pidBuilder, Long seed) { + this.seed = seed; + this.random = new Random(seed); + this.record = new PIDRecord(); + if (pidBuilder != null) { + this.record.setPid(pidBuilder.build()); + } else { + this.record.setPid(new PIDBuilder(seed).validPrefix().validSuffix().build()); + } + } + + public PIDRecord build() { + return this.record; + } + + public PIDRecordBuilder clone(PIDRecordBuilder builder) { + this.seed = builder.seed; + this.random = new Random(seed); + this.record = builder.build(); + return this; + } + + public PIDRecordBuilder withSeed(Long seed) { + this.seed = seed; + this.random = new Random(seed); + return this; + } + + public PIDRecordBuilder withPid(String pid) { + this.record.setPid(pid); + return this; + } + + public PIDRecordBuilder withEntries(Map> entries) { + this.record.setEntries(entries); + return this; + } + + public PIDRecordBuilder completeProfile() { + this.addNotDuplicate("21.T11148/076759916209e5d62bd5", "21.T11148/301c6f04763a16f0f72a", "KernelInformationProfile", true); + this.addNotDuplicate("21.T11148/397d831aa3a9d18eb52c", YESTERDAY.toString(), "dateCreated", true); + this.addNotDuplicate("21.T11148/8074aed799118ac263ad", "21.T11148/37d0f4689c6ea3301787", "digitalObjectPolicy", true); + this.addNotDuplicate("21.T11148/92e200311a56800b3e47", "{ \"sha256sum\": \"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\" }", "etag", true); + this.addNotDuplicate("21.T11148/aafd5fb4c7222e2d950a", NOW.toString(), "dateModified", true); + this.addNotDuplicate("21.T11148/b8457812905b83046284", "https://test.example/file001-" + Integer.toHexString(random.nextInt()), "digitalObjectLocation", true); + this.addNotDuplicate("21.T11148/c692273deb2772da307f", "1.0.0", "version", true); + this.addNotDuplicate("21.T11148/c83481d4bf467110e7c9", "21.T11148/ManuscriptPage", "digitalObjectType", true); + return this; + } + + public PIDRecordBuilder incompleteProfile() { + this.addNotDuplicate("21.T11148/076759916209e5d62bd5", "21.T11148/301c6f04763a16f0f72a", "KernelInformationProfile", true); + this.addNotDuplicate("21.T11148/397d831aa3a9d18eb52c", YESTERDAY.toString(), "dateCreated", true); + this.addNotDuplicate("21.T11148/8074aed799118ac263ad", "21.T11148/37d0f4689c6ea3301787", "digitalObjectPolicy", true); + return this; + } + + public PIDRecordBuilder invalidValues(int amount, String... keys) { + String[] availableKeys = {"21.T11148/076759916209e5d62bd5", "21.T11148/397d831aa3a9d18eb52c", "21.T11148/8074aed799118ac263ad", "21.T11148/92e200311a56800b3e47", "21.T11148/aafd5fb4c7222e2d950a", "21.T11148/b8457812905b83046284", "21.T11148/c692273deb2772da307f", "21.T11148/c83481d4bf467110e7c9"}; + + List keysToGenerateValuesFor = new ArrayList<>(); + if (amount == 0 && keys.length > 0) { + keysToGenerateValuesFor.addAll(Arrays.asList(keys)); + } else if (amount > 0 && keys.length >= amount) { + // add keys with limit of amount + keysToGenerateValuesFor.addAll(Arrays.asList(keys).subList(0, amount)); + } else if (amount > 0) { + // add all keys and generate values for the rest + keysToGenerateValuesFor.addAll(Arrays.asList(keys)); + for (int i = 0; i < amount - keys.length; i++) { + keysToGenerateValuesFor.add(availableKeys[random.nextInt(availableKeys.length)]); + } + } else { + // generate values for all keys + keysToGenerateValuesFor.addAll(Arrays.asList(availableKeys)); + } + + for (String key : keysToGenerateValuesFor) { + this.addNotDuplicate(key, "invalid-value-" + random.nextInt(), "key", false); + } + + return this; + } + + public PIDRecordBuilder invalidKeys(int amount) { + for (int i = 0; i < amount; i++) { + this.addNotDuplicate("invalid-key-" + generateRandomString(random.nextInt(5, 256)), generateRandomString(16), "KernelInformationProfile", false); + } + return this; + } + + public PIDRecordBuilder emptyRecord() { + String pid = this.record.getPid(); + this.record = new PIDRecord(); + return this; + } + + public PIDRecordBuilder nullRecord() { + this.record = null; + return this; + } + + /** + * Add an entry to the record if it does not exist yet. + * + * @param key key of the entry + * @param value value of the entry + * @param replace if true, replace the value of the entry if it already exists, even if it is a list of values + */ + private void addNotDuplicate(String key, String value, String name, Boolean replace) { + if (replace == null) replace = false; + if (this.record.getEntries().containsKey(key) && replace) { + this.record.removeAllValuesOf(key); + } + this.record.addEntry(key, name, value); + } + + private String generateRandomString(int length) { + StringBuilder result = new StringBuilder(); + for (int i = 0; i < length; i++) { + char c = (char) random.nextInt(Character.MAX_VALUE); + result.append(c); + } + return result.toString(); + } +} From c88eebc52c0d2846ace1d0e86d4999a40a62d669 Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Wed, 19 Feb 2025 16:04:24 +0100 Subject: [PATCH 071/108] added cloning capability for domain classes PIDRecord and PIDRecordEntry to enable cloning of the Builders Signed-off-by: Maximilian Inckmann --- .../kit/datamanager/pit/domain/PIDRecord.java | 100 ++++++++---- .../pit/domain/PIDRecordEntry.java | 31 +++- .../kit/datamanager/pit/web/PIDBuilder.java | 40 ++++- .../datamanager/pit/web/PIDRecordBuilder.java | 151 +++++++++++++++--- 4 files changed, 268 insertions(+), 54 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java index 9d67861e..47ac098a 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java @@ -1,17 +1,27 @@ +/* + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package edu.kit.datamanager.pit.domain; import com.fasterxml.jackson.annotation.JsonIgnore; - import edu.kit.datamanager.entities.EtagSupport; import edu.kit.datamanager.pit.pidsystem.impl.local.PidDatabaseObject; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.stream.Collectors; -import java.util.Set; /** * The internal representation for a PID record, offering methods to manipulate @@ -21,7 +31,7 @@ * communication or representation for the outside. In contrast, this is the * internal representation offering methods for manipulation. */ -public class PIDRecord implements EtagSupport { +public class PIDRecord implements EtagSupport, Cloneable { private String pid = ""; @@ -30,11 +40,12 @@ public class PIDRecord implements EtagSupport { /** * Creates an empty record without PID. */ - public PIDRecord() {} + public PIDRecord() { + } /** * Creates a record with the same content as the given representation. - * + * * @param dbo the given record representation. */ public PIDRecord(PidDatabaseObject dbo) { @@ -53,7 +64,7 @@ public PIDRecord(SimplePidRecord rec) { /** * Convenience setter / builder method. - * + * * @param pid the pid to set in this object. * @return this object (builder method). */ @@ -74,6 +85,10 @@ public Map> getEntries() { return entries; } + public void setEntries(Map> entries) { + this.entries = entries; + } + @JsonIgnore public Set getSimpleEntries() { return this.entries @@ -85,20 +100,16 @@ public Set getSimpleEntries() { .collect(Collectors.toSet()); } - public void setEntries(Map> entries) { - this.entries = entries; - } - public void addEntry(String propertyIdentifier, String propertyValue) { this.addEntry(propertyIdentifier, "", propertyValue); } /** * Adds a new key-name-value triplet. - * + * * @param propertyIdentifier the key/type PID. - * @param propertyName the human-readable name for the given key/type. - * @param propertyValue the value to this key/type. + * @param propertyName the human-readable name for the given key/type. + * @param propertyValue the value to this key/type. */ public void addEntry(String propertyIdentifier, String propertyName, String propertyValue) { if (propertyIdentifier.isEmpty()) { @@ -110,22 +121,22 @@ public void addEntry(String propertyIdentifier, String propertyName, String prop entry.setValue(propertyValue); this.entries - .computeIfAbsent(propertyIdentifier, key -> new ArrayList<>()) - .add(entry); + .computeIfAbsent(propertyIdentifier, key -> new ArrayList<>()) + .add(entry); } /** * Sets the name for a given key/type in all available pairs. - * + * * @param propertyIdentifier the key/type. - * @param name the new name. + * @param name the new name. */ @JsonIgnore public void setPropertyName(String propertyIdentifier, String name) { List propertyEntries = this.entries.get(propertyIdentifier); if (propertyEntries == null) { throw new IllegalArgumentException( - "Property identifier not listed in this record: " + propertyIdentifier); + "Property identifier not listed in this record: " + propertyIdentifier); } for (PIDRecordEntry entry : propertyEntries) { entry.setName(name); @@ -135,7 +146,7 @@ public void setPropertyName(String propertyIdentifier, String name) { /** * Check if there is a pair or triplet containing the given property (key/type) * is availeble in this record. - * + * * @param propertyIdentifier the key/type to search for. * @return true, if the property/key/type is present. */ @@ -158,7 +169,7 @@ public void removeAllValuesOf(String attribute) { /** * Get all properties contained in this record. - * + * * @return al contained properties. */ @JsonIgnore @@ -180,7 +191,7 @@ public String getPropertyValue(String propertyIdentifier) { /** * Get all values of a given property. - * + * * @param propertyIdentifier the given property identifier. * @return all values of the given property. */ @@ -194,7 +205,7 @@ public String[] getPropertyValues(String propertyIdentifier) { for (PIDRecordEntry e : entry) { values.add(e.getValue()); } - return values.toArray(new String[] {}); + return values.toArray(new String[]{}); } @Override @@ -215,9 +226,15 @@ public int hashCode() { */ @Override public boolean equals(Object obj) { - if (this == obj) {return true;} - if (obj == null) {return false;} - if (getClass() != obj.getClass()) {return false;} + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } PIDRecord other = (PIDRecord) obj; boolean isThisPidEmpty = pid == null || pid.isBlank(); @@ -240,13 +257,32 @@ public String toString() { /** * Calculates an etag for a record. - * + * * @return an etag, which is independent of any order or duplicates in the - * entries. + * entries. */ @JsonIgnore @Override public String getEtag() { return Integer.toString(this.hashCode()); } + + @Override + public PIDRecord clone() { + try { + PIDRecord clone = (PIDRecord) super.clone(); + clone.pid = this.pid; + clone.entries = new HashMap<>(); + for (Map.Entry> entry : this.entries.entrySet()) { + List entryList = new ArrayList<>(); + for (PIDRecordEntry e : entry.getValue()) { + entryList.add(e.clone()); + } + clone.entries.put(entry.getKey(), entryList); + } + return clone; + } catch (CloneNotSupportedException e) { + throw new AssertionError(); + } + } } diff --git a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java index c124f3ec..64f322e7 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java @@ -1,10 +1,39 @@ +/* + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package edu.kit.datamanager.pit.domain; import lombok.Data; @Data -public class PIDRecordEntry { +public class PIDRecordEntry implements Cloneable { private String key; private String name; private String value; + + @Override + public PIDRecordEntry clone() { + try { + PIDRecordEntry clone = (PIDRecordEntry) super.clone(); + clone.setKey(this.key); + clone.setName(this.name); + clone.setValue(this.value); + return clone; + } catch (CloneNotSupportedException e) { + throw new AssertionError(); + } + } } diff --git a/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java b/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java index a72c6d19..c576e1d2 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java +++ b/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java @@ -19,10 +19,11 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Objects; import java.util.Random; import java.util.UUID; -public class PIDBuilder { +public class PIDBuilder implements Cloneable { Long seed; private Random random; private String prefix; @@ -143,4 +144,41 @@ public PIDBuilder invalidCharactersSuffix() { this.suffix = result.toString(); return this; } + + @Override + public String toString() { + return "PIDBuilder{" + + "prefix='" + prefix + '\'' + + ", suffix='" + suffix + '\'' + + ", seed=" + seed + + '}'; + } + + @Override + public final boolean equals(Object o) { + if (!(o instanceof PIDBuilder that)) return false; + + return Objects.equals(prefix, that.prefix) && Objects.equals(suffix, that.suffix); + } + + @Override + public int hashCode() { + int result = Objects.hashCode(prefix); + result = 31 * result + Objects.hashCode(suffix); + return result; + } + + @Override + public PIDBuilder clone() { + try { + PIDBuilder clone = (PIDBuilder) super.clone(); + clone.seed = this.seed; + clone.random = new Random(this.seed); + clone.prefix = this.prefix; + clone.suffix = this.suffix; + return clone; + } catch (CloneNotSupportedException e) { + throw new AssertionError(); + } + } } diff --git a/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java b/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java index 762d18b0..2acb38ed 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java +++ b/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java @@ -17,29 +17,44 @@ package edu.kit.datamanager.pit.web; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.domain.PIDRecordEntry; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; -public class PIDRecordBuilder { +public class PIDRecordBuilder implements Cloneable { private static final Instant NOW = Instant.now().plus(1, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS); private static final Instant YESTERDAY = NOW.minus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); private static final Instant TOMORROW = NOW.plus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); + private static final String PROFILE_KEY = "21.T11148/076759916209e5d62bd5"; + private static final List KEYS_IN_PROFILE = new ArrayList<>(Arrays.stream(new String[]{"21.T11148/397d831aa3a9d18eb52c", "21.T11148/8074aed799118ac263ad", "21.T11148/92e200311a56800b3e47", "21.T11148/aafd5fb4c7222e2d950a", "21.T11148/b8457812905b83046284", "21.T11148/c692273deb2772da307f", "21.T11148/c83481d4bf467110e7c9"}).toList()); Long seed; private Random random; private PIDRecord record; + /** + * Create a PID record builder with a new PID builder and a new seed. + */ public PIDRecordBuilder() { this(null); } + /** + * Create a PID record builder with a given PID builder. If the PID builder is null, a valid PID is generated. + * + * @param pidBuilder PID builder to use to generate a PID for the record + */ public PIDRecordBuilder(PIDBuilder pidBuilder) { this(pidBuilder, new Random().nextLong()); } + /** + * Create a PID record builder with a given seed and a PID builder. If the PID builder is null, a valid PID is generated. + * + * @param pidBuilder PID builder to use to generate a PID for the record + * @param seed seed for the random generator + */ public PIDRecordBuilder(PIDBuilder pidBuilder, Long seed) { this.seed = seed; this.random = new Random(seed); @@ -51,35 +66,58 @@ public PIDRecordBuilder(PIDBuilder pidBuilder, Long seed) { } } + /** + * Build the PID record. + * The record is cloned before it is returned. + * This means that the builder can be used to build multiple records. + * + * @return the cloned PID record + */ public PIDRecord build() { - return this.record; - } - - public PIDRecordBuilder clone(PIDRecordBuilder builder) { - this.seed = builder.seed; - this.random = new Random(seed); - this.record = builder.build(); - return this; + return this.record.clone(); } + /** + * Set the seed for the random generator. + * + * @param seed seed to set + * @return this builder + */ public PIDRecordBuilder withSeed(Long seed) { this.seed = seed; this.random = new Random(seed); return this; } + /** + * Set the record to a given PID. + * + * @param pid PID to set + * @return this builder + */ public PIDRecordBuilder withPid(String pid) { this.record.setPid(pid); return this; } - public PIDRecordBuilder withEntries(Map> entries) { - this.record.setEntries(entries); + /** + * Set the record to a given PID record. + * + * @param record PID record to set + * @return this builder + */ + public PIDRecordBuilder withPIDRecord(PIDRecord record) { + this.record = record; return this; } + /** + * Add valid keys and values to the record that fulfill a predefined profile. If this is the first build step after construction, the PID record is valid. + * + * @return this builder (with valid PID record) + */ public PIDRecordBuilder completeProfile() { - this.addNotDuplicate("21.T11148/076759916209e5d62bd5", "21.T11148/301c6f04763a16f0f72a", "KernelInformationProfile", true); + this.addNotDuplicate(PROFILE_KEY, "21.T11148/301c6f04763a16f0f72a", "KernelInformationProfile", true); this.addNotDuplicate("21.T11148/397d831aa3a9d18eb52c", YESTERDAY.toString(), "dateCreated", true); this.addNotDuplicate("21.T11148/8074aed799118ac263ad", "21.T11148/37d0f4689c6ea3301787", "digitalObjectPolicy", true); this.addNotDuplicate("21.T11148/92e200311a56800b3e47", "{ \"sha256sum\": \"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\" }", "etag", true); @@ -90,16 +128,41 @@ public PIDRecordBuilder completeProfile() { return this; } + /** + * This method removes a random (mandatory) key from the profile. + * The profile is incomplete afterward. + * If nothing is in the record, the profile is, per definition, not fulfilled. + * + * @return this builder + */ public PIDRecordBuilder incompleteProfile() { - this.addNotDuplicate("21.T11148/076759916209e5d62bd5", "21.T11148/301c6f04763a16f0f72a", "KernelInformationProfile", true); - this.addNotDuplicate("21.T11148/397d831aa3a9d18eb52c", YESTERDAY.toString(), "dateCreated", true); - this.addNotDuplicate("21.T11148/8074aed799118ac263ad", "21.T11148/37d0f4689c6ea3301787", "digitalObjectPolicy", true); + this.addNotDuplicate(PROFILE_KEY, "21.T11148/301c6f04763a16f0f72a", "KernelInformationProfile", true); + + Set containedKeys = new HashSet<>(); + this.record.getEntries().keySet().forEach(key -> { + if (KEYS_IN_PROFILE.contains(key)) { + containedKeys.add(key); + } + }); + if (!containedKeys.isEmpty()) { + int randomIndex = random.nextInt(containedKeys.size()); + this.record.removeAllValuesOf(containedKeys.toArray()[randomIndex].toString()); + } return this; } + /** + * Add a given number of invalid values to the record. + * If the amount is smaller or equal to zero and no keys are specified, invalid values for a predefined list of valid keys (currently length 7) are generated. + * If you specify keys, the invalid values are generated for these keys. + * If the amount is greater than the number of keys, the remaining invalid values are generated for randomly selected keys from the predefined list. + * If the amount is greater than zero and no keys are specified, invalid values are generated for all predefined keys which are randomly repeated until the amount is reached. + * + * @param amount number of invalid values to add (optional) + * @param keys keys for which invalid values should be generated (optional) + * @return this builder + */ public PIDRecordBuilder invalidValues(int amount, String... keys) { - String[] availableKeys = {"21.T11148/076759916209e5d62bd5", "21.T11148/397d831aa3a9d18eb52c", "21.T11148/8074aed799118ac263ad", "21.T11148/92e200311a56800b3e47", "21.T11148/aafd5fb4c7222e2d950a", "21.T11148/b8457812905b83046284", "21.T11148/c692273deb2772da307f", "21.T11148/c83481d4bf467110e7c9"}; - List keysToGenerateValuesFor = new ArrayList<>(); if (amount == 0 && keys.length > 0) { keysToGenerateValuesFor.addAll(Arrays.asList(keys)); @@ -110,11 +173,12 @@ public PIDRecordBuilder invalidValues(int amount, String... keys) { // add all keys and generate values for the rest keysToGenerateValuesFor.addAll(Arrays.asList(keys)); for (int i = 0; i < amount - keys.length; i++) { - keysToGenerateValuesFor.add(availableKeys[random.nextInt(availableKeys.length)]); + //Get a random key from the predefined lis + keysToGenerateValuesFor.add(KEYS_IN_PROFILE.get(random.nextInt(KEYS_IN_PROFILE.size()))); } } else { // generate values for all keys - keysToGenerateValuesFor.addAll(Arrays.asList(availableKeys)); + keysToGenerateValuesFor.addAll(KEYS_IN_PROFILE); } for (String key : keysToGenerateValuesFor) { @@ -124,6 +188,12 @@ public PIDRecordBuilder invalidValues(int amount, String... keys) { return this; } + /** + * Add a given amount of invalid keys to the record. The keys are generated randomly. + * + * @param amount amount of invalid keys to add + * @return this builder + */ public PIDRecordBuilder invalidKeys(int amount) { for (int i = 0; i < amount; i++) { this.addNotDuplicate("invalid-key-" + generateRandomString(random.nextInt(5, 256)), generateRandomString(16), "KernelInformationProfile", false); @@ -131,12 +201,22 @@ public PIDRecordBuilder invalidKeys(int amount) { return this; } + /** + * Set the record to an empty record. The PID is not changed. All entries are removed. + * + * @return this builder + */ public PIDRecordBuilder emptyRecord() { String pid = this.record.getPid(); this.record = new PIDRecord(); return this; } + /** + * Set the record to null. + * + * @return this builder + */ public PIDRecordBuilder nullRecord() { this.record = null; return this; @@ -157,6 +237,12 @@ private void addNotDuplicate(String key, String value, String name, Boolean repl this.record.addEntry(key, name, value); } + /** + * Generate a random string of a given length. + * + * @param length length of the string + * @return random string + */ private String generateRandomString(int length) { StringBuilder result = new StringBuilder(); for (int i = 0; i < length; i++) { @@ -165,4 +251,29 @@ private String generateRandomString(int length) { } return result.toString(); } + + @Override + public final boolean equals(Object o) { + if (!(o instanceof PIDRecordBuilder that)) return false; + + return Objects.equals(record, that.record); + } + + @Override + public int hashCode() { + return Objects.hashCode(record); + } + + @Override + public PIDRecordBuilder clone() { + try { + PIDRecordBuilder clone = (PIDRecordBuilder) super.clone(); + clone.seed = this.seed; + clone.random = new Random(this.seed); + clone.record = this.record.clone(); + return clone; + } catch (CloneNotSupportedException e) { + throw new AssertionError(); + } + } } From a7e980d03a13d4188a9540ed37732029c1db7255 Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Wed, 19 Feb 2025 17:00:39 +0100 Subject: [PATCH 072/108] added capability to connect builders Signed-off-by: Maximilian Inckmann --- .../datamanager/pit/web/PIDRecordBuilder.java | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java b/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java index 2acb38ed..8deabaf6 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java +++ b/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java @@ -27,6 +27,8 @@ public class PIDRecordBuilder implements Cloneable { private static final Instant YESTERDAY = NOW.minus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); private static final Instant TOMORROW = NOW.plus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); private static final String PROFILE_KEY = "21.T11148/076759916209e5d62bd5"; + private static final String HAS_METADATA_KEY = "21.T11148/d0773859091aeb451528"; + private static final String IS_METADATA_FOR_KEY = "21.T11148/4fe7cde52629b61e3b82"; private static final List KEYS_IN_PROFILE = new ArrayList<>(Arrays.stream(new String[]{"21.T11148/397d831aa3a9d18eb52c", "21.T11148/8074aed799118ac263ad", "21.T11148/92e200311a56800b3e47", "21.T11148/aafd5fb4c7222e2d950a", "21.T11148/b8457812905b83046284", "21.T11148/c692273deb2772da307f", "21.T11148/c83481d4bf467110e7c9"}).toList()); Long seed; @@ -66,6 +68,38 @@ public PIDRecordBuilder(PIDBuilder pidBuilder, Long seed) { } } + /** + * This function connects multiple PIDRecordBuilders with each other. The builders are connected in a fully meshed way. + * + * @param a_to_b_key The key to use for the connection from A to B (forwards) + * @param b_to_a_key The key to use for the connection from B to A (backwards) + * @param allowDuplicateRelations If true, duplicate relations are allowed in the record, so that + * @param builders A list of PIDRecordBuilders to connect + * @return A list of connected PIDRecordBuilders + * @throws IllegalArgumentException If less than two builders are given + */ + public static List connectRecordBuilders(String a_to_b_key, String b_to_a_key, boolean allowDuplicateRelations, PIDRecordBuilder... builders) throws IllegalArgumentException { + if (builders.length < 2) { + throw new IllegalArgumentException("At least two builders are needed to connect them."); + } + if (a_to_b_key == null) a_to_b_key = HAS_METADATA_KEY; + if (b_to_a_key == null) b_to_a_key = IS_METADATA_FOR_KEY; + + List connectedBuilders = new ArrayList<>(); + // Connect the builders in a fully meshed way + for (int i = 0; i < builders.length; i++) { // Iterate over all builders + PIDRecordBuilder builder = builders[i]; // The builder to connect to all other builders to + for (int j = 0; j < builders.length; j++) { // Iterate over all builders + if (i != j) { // Do not self-connect the builder + builder.addConnection(a_to_b_key, !allowDuplicateRelations, builders[j]); // Connect builder to other builder + builders[j].addConnection(b_to_a_key, !allowDuplicateRelations, builder); // Connect other builder to builder + } + } + connectedBuilders.add(builder); // Add the connected builder to the list + } + return connectedBuilders; // Return the list of connected builders + } + /** * Build the PID record. * The record is cloned before it is returned. @@ -100,6 +134,25 @@ public PIDRecordBuilder withPid(String pid) { return this; } + /** + * This method adds a connection to another PID record to the current record. + * + * @param key key to use for the connection + * @param builders list of PIDRecordBuilders to connect to + * @param replaceIdentical if true, replace the connection if it already exists + * @return this builder + * @throws IllegalArgumentException if no builders are given + */ + public PIDRecordBuilder addConnection(String key, boolean replaceIdentical, PIDRecordBuilder... builders) throws IllegalArgumentException { + if (builders.length == 0) { + throw new IllegalArgumentException("At least one builder is needed to connect."); + } + for (PIDRecordBuilder builder : builders) { + this.addNotDuplicate(key, builder.record.getPid(), "connectedPID", replaceIdentical); + } + return this; + } + /** * Set the record to a given PID record. * @@ -229,8 +282,7 @@ public PIDRecordBuilder nullRecord() { * @param value value of the entry * @param replace if true, replace the value of the entry if it already exists, even if it is a list of values */ - private void addNotDuplicate(String key, String value, String name, Boolean replace) { - if (replace == null) replace = false; + private void addNotDuplicate(String key, String value, String name, boolean replace) { if (this.record.getEntries().containsKey(key) && replace) { this.record.removeAllValuesOf(key); } @@ -276,4 +328,13 @@ public PIDRecordBuilder clone() { throw new AssertionError(); } } + + @Override + public String toString() { + return "PIDRecordBuilder{" + + "seed=" + seed + + ", random=" + random + + ", record=" + record + + '}'; + } } From 2a565921ea57d872b0d5ccf35e4ab00926fc033e Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 4 Mar 2025 13:05:18 +0100 Subject: [PATCH 073/108] ci: create multi-arch docker images instead of amd64 only --- .github/workflows/docker-publish.yml | 58 +++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 46720afc..707820b4 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,4 +1,6 @@ # Create and publish a Docker image on GitHub +# References: +# - https://docs.docker.com/build/ci/github-actions/multi-platform/ name: Create and publish a Docker image # Configures this workflow to run every time a change @@ -33,10 +35,24 @@ jobs: permissions: contents: read packages: write - # + strategy: + matrix: + platform: [linux/amd64, linux/arm64, linux/arm64/v8, linux/ppc64le, linux/riscv64, linux/s390x, windows/amd64] steps: - name: Checkout repository uses: actions/checkout@v4 + + - name: Set up Docker + uses: docker/setup-docker-action@v4 + with: + daemon-config: | + { + "debug": true, + "features": { + "containerd-snapshotter": true + } + } + # Uses the `docker/login-action` action to log in to the Container # registry using the account and password that will publish the packages. # Once published, the packages are scoped to the account defined here. @@ -46,6 +62,14 @@ jobs: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + + # Add support for more platforms with QEMU + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) # to extract tags and labels that will be applied to the specified image. # The `id` "meta" allows the output of this step to be referenced in a @@ -58,6 +82,9 @@ jobs: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} labels: | org.opencontainers.image.title=Typed-PID-Maker + env: + DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index + # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. @@ -69,6 +96,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + platforms: ${{ matrix.platform }} build-and-push-image-of-branch: runs-on: ubuntu-latest if: contains(github.ref_name, '-v') @@ -84,11 +113,25 @@ jobs: TAG: ${{ github.ref_name }} id: split run: echo "branch=${TAG%-v*}" >> $GITHUB_OUTPUT + - name: Test variable run: | echo ${{ steps.split.outputs.branch }} + - name: Checkout repository uses: actions/checkout@v4 + + - name: Set up Docker + uses: docker/setup-docker-action@v4 + with: + daemon-config: | + { + "debug": true, + "features": { + "containerd-snapshotter": true + } + } + # Uses the `docker/login-action` action to log in to the Container # registry using the account and password that will publish the packages. # Once published, the packages are scoped to the account defined here. @@ -98,6 +141,14 @@ jobs: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + + # Add support for more platforms with QEMU + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) # to extract tags and labels that will be applied to the specified image. # The `id` "meta" allows the output of this step to be referenced in a @@ -110,6 +161,9 @@ jobs: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-${{steps.split.outputs.branch}} labels: | org.opencontainers.image.title=Typed-PID-Maker + env: + DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index + # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. @@ -121,3 +175,5 @@ jobs: push: true tags: ${{ steps.meta-branch.outputs.tags }} labels: ${{ steps.meta-branch.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + platforms: ${{ matrix.platform }} From 8294bcaad33c5d8b3b05ece008534c84f1019b37 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 4 Mar 2025 13:39:37 +0100 Subject: [PATCH 074/108] ci: remove custom docker setup and use only documented platform types for now --- .github/workflows/docker-publish.yml | 30 +++------------------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 707820b4..7c0264e4 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -35,24 +35,10 @@ jobs: permissions: contents: read packages: write - strategy: - matrix: - platform: [linux/amd64, linux/arm64, linux/arm64/v8, linux/ppc64le, linux/riscv64, linux/s390x, windows/amd64] steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Docker - uses: docker/setup-docker-action@v4 - with: - daemon-config: | - { - "debug": true, - "features": { - "containerd-snapshotter": true - } - } - # Uses the `docker/login-action` action to log in to the Container # registry using the account and password that will publish the packages. # Once published, the packages are scoped to the account defined here. @@ -97,7 +83,8 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} annotations: ${{ steps.meta.outputs.annotations }} - platforms: ${{ matrix.platform }} + platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/riscv64,linux/s390x,windows/amd64 + build-and-push-image-of-branch: runs-on: ubuntu-latest if: contains(github.ref_name, '-v') @@ -121,17 +108,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Docker - uses: docker/setup-docker-action@v4 - with: - daemon-config: | - { - "debug": true, - "features": { - "containerd-snapshotter": true - } - } - # Uses the `docker/login-action` action to log in to the Container # registry using the account and password that will publish the packages. # Once published, the packages are scoped to the account defined here. @@ -176,4 +152,4 @@ jobs: tags: ${{ steps.meta-branch.outputs.tags }} labels: ${{ steps.meta-branch.outputs.labels }} annotations: ${{ steps.meta.outputs.annotations }} - platforms: ${{ matrix.platform }} + platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/riscv64,linux/s390x,windows/amd64 From 23c113410a5416e9b0441e84d13be44343d69628 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 4 Mar 2025 13:52:32 +0100 Subject: [PATCH 075/108] ci: add custom docker setup again (revert removal) --- .github/workflows/docker-publish.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7c0264e4..1c103f4c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -39,6 +39,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up Docker + uses: docker/setup-docker-action@v4 + with: + daemon-config: | + { + "debug": true, + "features": { + "containerd-snapshotter": true + } + } + # Uses the `docker/login-action` action to log in to the Container # registry using the account and password that will publish the packages. # Once published, the packages are scoped to the account defined here. @@ -108,6 +119,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up Docker + uses: docker/setup-docker-action@v4 + with: + daemon-config: | + { + "debug": true, + "features": { + "containerd-snapshotter": true + } + } + # Uses the `docker/login-action` action to log in to the Container # registry using the account and password that will publish the packages. # Once published, the packages are scoped to the account defined here. From ed8da033a5f902ccd9d85763d313be092f36cb21 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 4 Mar 2025 13:53:03 +0100 Subject: [PATCH 076/108] ci: fix wrong step reference name for docker annotations --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 1c103f4c..77ac47bb 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -173,5 +173,5 @@ jobs: push: true tags: ${{ steps.meta-branch.outputs.tags }} labels: ${{ steps.meta-branch.outputs.labels }} - annotations: ${{ steps.meta.outputs.annotations }} + annotations: ${{ steps.meta-branch.outputs.annotations }} platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/riscv64,linux/s390x,windows/amd64 From 3eb410bbbbc194a20edc1d27107d2c82c0587f33 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 4 Mar 2025 14:00:02 +0100 Subject: [PATCH 077/108] ci: for now, restrict the container targets to what we actually need --- .github/workflows/docker-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 77ac47bb..4bc7372b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -94,7 +94,7 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} annotations: ${{ steps.meta.outputs.annotations }} - platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/riscv64,linux/s390x,windows/amd64 + platforms: linux/amd64,linux/arm64,windows/amd64 build-and-push-image-of-branch: runs-on: ubuntu-latest @@ -174,4 +174,4 @@ jobs: tags: ${{ steps.meta-branch.outputs.tags }} labels: ${{ steps.meta-branch.outputs.labels }} annotations: ${{ steps.meta-branch.outputs.annotations }} - platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/riscv64,linux/s390x,windows/amd64 + platforms: linux/amd64,linux/arm64,windows/amd64 From 71b6d8f966e9c88f19e575ce2847543b78eb186e Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 4 Mar 2025 14:13:00 +0100 Subject: [PATCH 078/108] ci: pre-build java, then reuse in images --- .github/workflows/docker-publish.yml | 35 ++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 4bc7372b..2eabd201 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -22,6 +22,9 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: kit-data-manager/Typed-PID-Maker # repo name != app name in our case! + jdk: 21 + distro: 'temurin' + DOCKER_PLATFORMS: linux/amd64,linux/arm64,windows/amd64 # Two jobs for creating and pushing Docker image # - build-and-push-image -> triggered by commits on main and tagging with semantic version (e.g.: v1.2.3) @@ -39,6 +42,18 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up openJDK version + uses: actions/setup-java@v4 + with: + java-version: ${{ env.jdk }} + distribution: ${{ env.distro }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build and Test with Gradle + run: ./gradlew -Dprofile=verbose build + - name: Set up Docker uses: docker/setup-docker-action@v4 with: @@ -89,12 +104,12 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: ./docker/Dockerfile-build-in-image + file: ./docker/Dockerfile-reuse-local-build push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} annotations: ${{ steps.meta.outputs.annotations }} - platforms: linux/amd64,linux/arm64,windows/amd64 + platforms: ${{ env.DOCKER_PLATFORMS }} build-and-push-image-of-branch: runs-on: ubuntu-latest @@ -119,6 +134,18 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up openJDK version + uses: actions/setup-java@v4 + with: + java-version: ${{ env.jdk }} + distribution: ${{ env.distro }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build and Test with Gradle + run: ./gradlew -Dprofile=verbose build + - name: Set up Docker uses: docker/setup-docker-action@v4 with: @@ -169,9 +196,9 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: ./docker/Dockerfile-build-in-image + file: ./docker/Dockerfile-reuse-local-build push: true tags: ${{ steps.meta-branch.outputs.tags }} labels: ${{ steps.meta-branch.outputs.labels }} annotations: ${{ steps.meta-branch.outputs.annotations }} - platforms: linux/amd64,linux/arm64,windows/amd64 + platforms: ${{ env.DOCKER_PLATFORMS }} From 10a34aa6cc0dfb5b33104245b9e93080d9dae9a6 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 4 Mar 2025 14:52:00 +0100 Subject: [PATCH 079/108] ci: use windows runner for docker ci --- .github/workflows/docker-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 2eabd201..758f9280 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -31,7 +31,7 @@ env: # - build-and-push-image-of-branch -> triggered by tags matching '*-v*' (e.g.: Version_1-v1.2.3) jobs: build-and-push-image: - runs-on: ubuntu-latest + runs-on: windows-latest if: ${{ contains(github.ref_name, '-v') == failure() }} # Sets the permissions granted to the `GITHUB_TOKEN` # for the actions in this job. @@ -112,7 +112,7 @@ jobs: platforms: ${{ env.DOCKER_PLATFORMS }} build-and-push-image-of-branch: - runs-on: ubuntu-latest + runs-on: windows-latest if: contains(github.ref_name, '-v') # Sets the permissions granted to the `GITHUB_TOKEN` # for the actions in this job. From 2aba06f55780d12fd9655670f15a5c2c5b49226c Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 4 Mar 2025 14:58:48 +0100 Subject: [PATCH 080/108] ci: fix windows runner for docker ci --- .github/workflows/docker-publish.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 758f9280..e00ee1b6 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -32,6 +32,9 @@ env: jobs: build-and-push-image: runs-on: windows-latest + defaults: + run: + shell: bash if: ${{ contains(github.ref_name, '-v') == failure() }} # Sets the permissions granted to the `GITHUB_TOKEN` # for the actions in this job. @@ -113,6 +116,9 @@ jobs: build-and-push-image-of-branch: runs-on: windows-latest + defaults: + run: + shell: bash if: contains(github.ref_name, '-v') # Sets the permissions granted to the `GITHUB_TOKEN` # for the actions in this job. From 31077e3a0e02e5641c3d9b15516357ff01de6bcb Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 4 Mar 2025 15:23:18 +0100 Subject: [PATCH 081/108] Revert "ci: fix windows runner for docker ci" This reverts commit 2aba06f55780d12fd9655670f15a5c2c5b49226c. --- .github/workflows/docker-publish.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index e00ee1b6..758f9280 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -32,9 +32,6 @@ env: jobs: build-and-push-image: runs-on: windows-latest - defaults: - run: - shell: bash if: ${{ contains(github.ref_name, '-v') == failure() }} # Sets the permissions granted to the `GITHUB_TOKEN` # for the actions in this job. @@ -116,9 +113,6 @@ jobs: build-and-push-image-of-branch: runs-on: windows-latest - defaults: - run: - shell: bash if: contains(github.ref_name, '-v') # Sets the permissions granted to the `GITHUB_TOKEN` # for the actions in this job. From 89a36817ad2cbefdfb1d679ca5ae44005afe6e9a Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 4 Mar 2025 15:23:46 +0100 Subject: [PATCH 082/108] Revert "ci: use windows runner for docker ci" This reverts commit 10a34aa6cc0dfb5b33104245b9e93080d9dae9a6. --- .github/workflows/docker-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 758f9280..2eabd201 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -31,7 +31,7 @@ env: # - build-and-push-image-of-branch -> triggered by tags matching '*-v*' (e.g.: Version_1-v1.2.3) jobs: build-and-push-image: - runs-on: windows-latest + runs-on: ubuntu-latest if: ${{ contains(github.ref_name, '-v') == failure() }} # Sets the permissions granted to the `GITHUB_TOKEN` # for the actions in this job. @@ -112,7 +112,7 @@ jobs: platforms: ${{ env.DOCKER_PLATFORMS }} build-and-push-image-of-branch: - runs-on: windows-latest + runs-on: ubuntu-latest if: contains(github.ref_name, '-v') # Sets the permissions granted to the `GITHUB_TOKEN` # for the actions in this job. From c2551d63ca10b7cda16d7aa30ef33dc53ac3df0e Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 4 Mar 2025 15:24:33 +0100 Subject: [PATCH 083/108] ci: do not create image for windows for now --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 2eabd201..5ef18927 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -24,7 +24,7 @@ env: IMAGE_NAME: kit-data-manager/Typed-PID-Maker # repo name != app name in our case! jdk: 21 distro: 'temurin' - DOCKER_PLATFORMS: linux/amd64,linux/arm64,windows/amd64 + DOCKER_PLATFORMS: linux/amd64,linux/arm64 # Two jobs for creating and pushing Docker image # - build-and-push-image -> triggered by commits on main and tagging with semantic version (e.g.: v1.2.3) From ffdd7e90424ed12b5581c89e41f2a693e30477c4 Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Tue, 3 Jun 2025 18:02:54 +0200 Subject: [PATCH 084/108] added tests Signed-off-by: Maximilian Inckmann --- .../pit/web/impl/TypingRESTResourceImpl.java | 7 + .../pit/web/ConnectedPIDsTest.java | 756 ++++++++++++++++++ .../datamanager/pit/web/PIDRecordBuilder.java | 62 +- 3 files changed, 793 insertions(+), 32 deletions(-) create mode 100644 src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index d67c2975..b32b0ba0 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -99,6 +99,10 @@ public ResponseEntity> createPIDs( HttpServletResponse response, UriComponentsBuilder uriBuilder ) throws IOException, RecordValidationException, ExternalServiceException { + if (rec == null || rec.isEmpty()) { + LOG.warn("No records provided for PID creation."); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Collections.emptyList()); + } Instant startTime = Instant.now(); LOG.info("Creating PIDs for {} records.", rec.size()); String prefix = this.typingService.getPrefix().orElseThrow(() -> new IOException("No prefix configured.")); @@ -194,6 +198,9 @@ private Map generatePIDMapping(List rec, boolean dryr Map pidMappings = new HashMap<>(); for (PIDRecord pidRecord : rec) { String internalPID = pidRecord.getPid(); // the internal PID is the one given by the user + if (internalPID == null) { + internalPID = ""; // if no PID was given, we set it to an empty string + } if (!internalPID.isBlank() && pidMappings.containsKey(internalPID)) { // check if the internal PID was already used // This internal PID was already used by some other record in the same request. throw new RecordValidationException(pidRecord, "The PID " + internalPID + " was used for multiple records in the same request."); diff --git a/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java b/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java new file mode 100644 index 00000000..0a552db6 --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java @@ -0,0 +1,756 @@ +/* + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.kit.datamanager.pit.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import edu.kit.datamanager.pit.domain.PIDRecord; +import edu.kit.datamanager.pit.pidgeneration.PidSuffixGenerator; +import edu.kit.datamanager.pit.pidlog.KnownPidsDao; +import edu.kit.datamanager.pit.pitservice.ITypingService; +import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultHandlers; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.web.context.WebApplicationContext; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; + +@Slf4j +@AutoConfigureMockMvc +@SpringBootTest +@TestPropertySource("/test/application-test.properties") +@ActiveProfiles("test") +class ConnectedPIDsTest { + private static final Instant NOW = Instant.now().plus(1, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS); + private static final Instant YESTERDAY = NOW.minus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); + private static final Instant TOMORROW = NOW.plus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); + private static final int RECORD_COUNT = 16; // FIXME: Use in all tests that require multiple records + private static final int LARGE_RECORD_COUNT = 200; + + // Valid DTR keys for testing connections + private static final String[] VALID_CONNECTION_KEYS = { + "21.T11148/432132bdbd946b2baf2b", + "21.T11148/ab53242825e85a0a7f76", + "21.T11148/2a1cad55473b20407c78" + }; + + @Autowired + ITypingService typingService; + @Autowired + ITypeRegistry typeRegistry; + @Autowired + private WebApplicationContext webApplicationContext; + @Autowired + private PidSuffixGenerator pidGenerator; + @Autowired + private ApplicationProperties appProps; + private MockMvc mockMvc; + private ObjectMapper mapper; + @Autowired + private KnownPidsDao knownPidsDao; + + @BeforeEach + void setup() throws Exception { + this.mockMvc = webAppContextSetup(webApplicationContext).build(); + this.mapper = new ObjectMapper(); + knownPidsDao.deleteAll(); + } + + @Test + void checkTestSetup() { + assertNotNull(mockMvc); + assertNotNull(mapper); + assertNotNull(knownPidsDao); + assertEquals(0, knownPidsDao.count()); + } + + @Test + @DisplayName("Test comprehensive PIDBuilder functionality") + void testPIDBuilderAllMethods() { + Long testSeed = 12345L; + + // Test default constructor + PIDBuilder defaultBuilder = new PIDBuilder(); + assertNotNull(defaultBuilder); + assertNotNull(defaultBuilder.build()); + + // Test constructor with seed + PIDBuilder seededBuilder = new PIDBuilder(testSeed); + assertNotNull(seededBuilder); + assertEquals(testSeed, seededBuilder.seed); + + // Test withSeed method + PIDBuilder seedModified = new PIDBuilder().withSeed(testSeed); + assertEquals(testSeed, seedModified.seed); + + // Test all prefix methods + PIDBuilder validPrefixBuilder = new PIDBuilder(testSeed).validPrefix(); + String validPid = validPrefixBuilder.build(); + assertTrue(validPid.startsWith("sandboxed/")); + + PIDBuilder unauthorizedPrefixBuilder = new PIDBuilder(testSeed).unauthorizedPrefix(); + String unauthorizedPid = unauthorizedPrefixBuilder.build(); + assertTrue(unauthorizedPid.startsWith("0.NA/")); + + PIDBuilder emptyPrefixBuilder = new PIDBuilder(testSeed).emptyPrefix(); + String emptyPrefixPid = emptyPrefixBuilder.build(); + assertTrue(emptyPrefixPid.startsWith("/")); + + PIDBuilder invalidPrefixBuilder = new PIDBuilder(testSeed).invalidCharactersPrefix(); + String invalidPrefixPid = invalidPrefixBuilder.build(); + assertNotNull(invalidPrefixPid); + + // Test withPrefix method + PIDBuilder customPrefixBuilder = new PIDBuilder(testSeed).withPrefix("custom.prefix"); + String customPrefixPid = customPrefixBuilder.build(); + assertTrue(customPrefixPid.startsWith("custom.prefix/")); + + // Test all suffix methods + PIDBuilder validSuffixBuilder = new PIDBuilder(testSeed).validPrefix().validSuffix(); + String validSuffixPid = validSuffixBuilder.build(); + assertNotNull(validSuffixPid); + + PIDBuilder emptySuffixBuilder = new PIDBuilder(testSeed).validPrefix().emptySuffix(); + String emptySuffixPid = emptySuffixBuilder.build(); + assertTrue(emptySuffixPid.endsWith("/")); + + PIDBuilder invalidSuffixBuilder = new PIDBuilder(testSeed).validPrefix().invalidCharactersSuffix(); + String invalidSuffixPid = invalidSuffixBuilder.build(); + assertNotNull(invalidSuffixPid); + + // Test withSuffix method + PIDBuilder customSuffixBuilder = new PIDBuilder(testSeed).validPrefix().withSuffix("custom-suffix"); + String customSuffixPid = customSuffixBuilder.build(); + assertTrue(customSuffixPid.endsWith("custom-suffix")); + + // Test clone method + PIDBuilder originalBuilder = new PIDBuilder(testSeed).validPrefix().validSuffix(); + PIDBuilder clonedBuilder = originalBuilder.clone(); + assertEquals(originalBuilder.build(), clonedBuilder.build()); + assertNotSame(originalBuilder, clonedBuilder); + + // Test clone(PIDBuilder) method + PIDBuilder targetBuilder = new PIDBuilder(); + targetBuilder.clone(originalBuilder); + assertEquals(originalBuilder.build(), targetBuilder.build()); + + // Test equals and hashCode + PIDBuilder builder1 = new PIDBuilder(testSeed).validPrefix().validSuffix(); + PIDBuilder builder2 = new PIDBuilder(testSeed).validPrefix().validSuffix(); + assertEquals(builder1, builder2); + assertEquals(builder1.hashCode(), builder2.hashCode()); + + // Test toString + assertNotNull(originalBuilder.toString()); + assertTrue(originalBuilder.toString().contains("PIDBuilder")); + } + + @Test + @DisplayName("Test comprehensive PIDRecordBuilder functionality") + void testPIDRecordBuilderAllMethods() { + Long testSeed = 67890L; + + // Test default constructor + PIDRecordBuilder defaultBuilder = new PIDRecordBuilder(); + assertNotNull(defaultBuilder); + assertNotNull(defaultBuilder.build()); + + // Test constructor with PIDBuilder + PIDBuilder pidBuilder = new PIDBuilder(testSeed).validPrefix().validSuffix(); + PIDRecordBuilder builderWithPid = new PIDRecordBuilder(pidBuilder); + assertNotNull(builderWithPid); + assertEquals(pidBuilder.build(), builderWithPid.build().getPid()); + + // Test constructor with PIDBuilder and seed + PIDRecordBuilder builderWithSeed = new PIDRecordBuilder(pidBuilder, testSeed); + assertNotNull(builderWithSeed); + assertEquals(testSeed, builderWithSeed.seed); + + // Test withSeed method + PIDRecordBuilder seedModified = new PIDRecordBuilder().withSeed(testSeed); + assertEquals(testSeed, seedModified.seed); + + // Test withPid method + String customPid = "test/custom-pid"; + PIDRecordBuilder pidModified = new PIDRecordBuilder().withPid(customPid); + assertEquals(customPid, pidModified.build().getPid()); + + // Test completeProfile method + PIDRecordBuilder profileBuilder = new PIDRecordBuilder().completeProfile(); + PIDRecord profileRecord = profileBuilder.build(); + assertNotNull(profileRecord); + assertTrue(profileRecord.getEntries().size() > 0); + + // Test incompleteProfile method + PIDRecordBuilder incompleteBuilder = new PIDRecordBuilder().completeProfile().incompleteProfile();//FIXME: something goes wrong here (ConcurrentModificationException) + PIDRecord incompleteRecord = incompleteBuilder.build(); + assertNotNull(incompleteRecord); + + // Test invalidValues method with different parameters + PIDRecordBuilder invalidValuesBuilder1 = new PIDRecordBuilder().invalidValues(3); + PIDRecord invalidRecord1 = invalidValuesBuilder1.build(); + assertNotNull(invalidRecord1); + + PIDRecordBuilder invalidValuesBuilder2 = new PIDRecordBuilder().invalidValues(2, "21.T11148/397d831aa3a9d18eb52c"); + PIDRecord invalidRecord2 = invalidValuesBuilder2.build(); + assertNotNull(invalidRecord2); + + PIDRecordBuilder invalidValuesBuilder3 = new PIDRecordBuilder().invalidValues(0); + PIDRecord invalidRecord3 = invalidValuesBuilder3.build(); + assertNotNull(invalidRecord3); + + // Test invalidKeys method + PIDRecordBuilder invalidKeysBuilder = new PIDRecordBuilder().invalidKeys(3); + PIDRecord invalidKeysRecord = invalidKeysBuilder.build(); + assertNotNull(invalidKeysRecord); + assertTrue(invalidKeysRecord.getEntries().size() >= 3); + + // Test emptyRecord method + PIDRecordBuilder emptyBuilder = new PIDRecordBuilder().completeProfile().emptyRecord(); + PIDRecord emptyRecord = emptyBuilder.build(); + assertNotNull(emptyRecord); + assertEquals(0, emptyRecord.getEntries().size()); + + // Test nullRecord method + PIDRecordBuilder nullBuilder = new PIDRecordBuilder().nullRecord(); + assertThrows(Exception.class, () -> nullBuilder.build()); + + // Test withPIDRecord method + PIDRecord existingRecord = new PIDRecord().withPID("test/existing"); + existingRecord.addEntry("test.key", "test.value"); + PIDRecordBuilder recordBuilder = new PIDRecordBuilder().withPIDRecord(existingRecord); + assertEquals(existingRecord.getPid(), recordBuilder.build().getPid()); + + // Test clone method + PIDRecordBuilder originalRecordBuilder = new PIDRecordBuilder().completeProfile(); + PIDRecordBuilder clonedRecordBuilder = originalRecordBuilder.clone(); + assertNotSame(originalRecordBuilder, clonedRecordBuilder); + assertEquals(originalRecordBuilder.seed, clonedRecordBuilder.seed); + + // Test equals and hashCode + PIDRecordBuilder builder1 = new PIDRecordBuilder(null, testSeed).completeProfile(); + PIDRecordBuilder builder2 = new PIDRecordBuilder(null, testSeed).completeProfile(); + // Note: equals might not be equal due to random elements, but we test the method exists + assertNotNull(builder1.equals(builder2)); + assertNotNull(builder1.hashCode()); + + // Test toString + assertNotNull(originalRecordBuilder.toString()); + assertTrue(originalRecordBuilder.toString().contains("PIDRecordBuilder")); + } + + @Test + @DisplayName("Test PIDRecordBuilder connection functionality") + void testPIDRecordBuilderConnections() { + Long testSeed = 111L; + + // Create multiple builders for connection testing + PIDRecordBuilder builder1 = new PIDRecordBuilder(null, testSeed).completeProfile(); + PIDRecordBuilder builder2 = new PIDRecordBuilder(null, testSeed + 1).completeProfile(); + PIDRecordBuilder builder3 = new PIDRecordBuilder(null, testSeed + 2).completeProfile(); + + // Test addConnection method + String connectionKey = "21.T11148/d0773859091aeb451528"; + builder1.addConnection(connectionKey, false, builder2, builder3); + + PIDRecord connectedRecord = builder1.build(); + assertTrue(connectedRecord.hasProperty(connectionKey)); + + // Test addConnection with replace + builder1.addConnection(connectionKey, true, builder2); + PIDRecord replacedRecord = builder1.build(); + assertTrue(replacedRecord.hasProperty(connectionKey)); + + // Test addConnection error case + assertThrows(IllegalArgumentException.class, () -> + builder1.addConnection(connectionKey, false)); + + // Test connectRecordBuilders static method with default keys + List connectedBuilders = PIDRecordBuilder.connectRecordBuilders( + null, null, false, builder1, builder2, builder3); + + assertEquals(3, connectedBuilders.size()); + + // Verify connections were established + for (PIDRecordBuilder builder : connectedBuilders) { + PIDRecord record = builder.build(); + assertTrue(record.hasProperty("21.T11148/d0773859091aeb451528") || + record.hasProperty("21.T11148/4fe7cde52629b61e3b82")); + } + + // Test connectRecordBuilders with custom keys + PIDRecordBuilder builder4 = new PIDRecordBuilder(null, testSeed + 3).completeProfile(); + PIDRecordBuilder builder5 = new PIDRecordBuilder(null, testSeed + 4).completeProfile(); + + List customConnectedBuilders = PIDRecordBuilder.connectRecordBuilders( + "custom.forward.key", "custom.backward.key", true, builder4, builder5); + + assertEquals(2, customConnectedBuilders.size()); + + // Test connectRecordBuilders error case + assertThrows(IllegalArgumentException.class, () -> + PIDRecordBuilder.connectRecordBuilders(null, null, false, builder1)); + } + + @Test + @DisplayName("Test valid connected records creation") + void checkValidConnectedRecords() throws Exception { + // Create connected records using all builder functionality + Long baseSeed = 12345L; + + List records = new ArrayList<>(); + + // Use different PIDBuilder configurations + PIDBuilder[] pidBuilders = { + new PIDBuilder(baseSeed).validPrefix().validSuffix(), + new PIDBuilder(baseSeed + 1).validPrefix().validSuffix(), + new PIDBuilder(baseSeed + 2).validPrefix().validSuffix() + }; + + PIDRecordBuilder[] builders = new PIDRecordBuilder[pidBuilders.length]; + for (int i = 0; i < pidBuilders.length; i++) { + builders[i] = new PIDRecordBuilder(pidBuilders[i], baseSeed + i) + .completeProfile() + .withSeed(baseSeed + i); + } + + // Connect the builders + PIDRecordBuilder.connectRecordBuilders(null, null, false, builders); + + // Build the records + for (PIDRecordBuilder builder : builders) { + records.add(builder.build()); + } + + // Submit to API + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()); + + assertEquals(records.size(), knownPidsDao.count()); + } + + @Test + @DisplayName("Test single valid record creation") + void testCreateSingleValidRecord() throws Exception { + // Use comprehensive PIDBuilder configuration + PIDBuilder pidBuilder = new PIDBuilder(999L) + .withSeed(999L) + .validPrefix() + .validSuffix(); + + PIDRecord record = new PIDRecordBuilder(pidBuilder) + .withSeed(999L) + .completeProfile() + .build(); + + List records = List.of(record); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()); + + assertEquals(1, knownPidsDao.count()); + } + + @Test + @DisplayName("Test empty list") + void testCreateEmptyList() throws Exception { + List emptyRecords = new ArrayList<>(); + String jsonContent = mapper.writeValueAsString(emptyRecords); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + + assertEquals(0, knownPidsDao.count()); + } + + @Test + @DisplayName("Test dryrun functionality") + void testDryRun() throws Exception { + PIDRecord record = new PIDRecordBuilder() + .completeProfile() + .build(); + + List records = List.of(record); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent) + .param("dryrun", "true")) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isOk()); + + assertEquals(0, knownPidsDao.count()); + } + + @Test + @DisplayName("Test invalid JSON format") + void testInvalidJsonFormat() throws Exception { + String invalidJson = "{ invalid json }"; + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(invalidJson)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + } + + @Test + @DisplayName("Test unsupported media type") + void testUnsupportedMediaType() throws Exception { + PIDRecord record = new PIDRecordBuilder().completeProfile().build(); + List records = List.of(record); + String content = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.TEXT_PLAIN) + .content(content)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isUnsupportedMediaType()); + } + + @Test + @DisplayName("Test records with circular references") + void testCircularReferences() throws Exception { + // Create builders with circular connections + PIDRecordBuilder builder1 = new PIDRecordBuilder(null, 100L).completeProfile(); + PIDRecordBuilder builder2 = new PIDRecordBuilder(null, 101L).completeProfile(); + + // Create circular reference + builder1.addConnection("21.T11148/d0773859091aeb451528", false, builder2); + builder2.addConnection("21.T11148/4fe7cde52629b61e3b82", false, builder1); + + List records = List.of(builder1.build(), builder2.build()); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()); + + assertEquals(2, knownPidsDao.count()); + } + + @Test + @DisplayName("Test records with duplicate temporary PIDs") + void testDuplicateTemporaryPids() throws Exception { + // Create records with same PID using same seed + Long sameSeed = 555L; + PIDBuilder sameBuilder = new PIDBuilder(sameSeed).validPrefix().validSuffix(); + + PIDRecord record1 = new PIDRecordBuilder(sameBuilder.clone(), sameSeed).completeProfile().build(); + PIDRecord record2 = new PIDRecordBuilder(sameBuilder.clone(), sameSeed).completeProfile().build(); + + List records = List.of(record1, record2); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + } + + @Test + @DisplayName("Test records with missing entries") + void testRecordsWithMissingEntries() throws Exception { + // Create incomplete record + PIDRecord incompleteRecord = new PIDRecordBuilder() + .incompleteProfile() + .build(); + + List records = List.of(incompleteRecord); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); //FIXME: This should fail with 400 but currently does not. + } + + @Test + @DisplayName("Test large number of connected records") + void testLargeNumberOfConnectedRecords() throws Exception { + List builders = new ArrayList<>(); + + for (int i = 0; i < LARGE_RECORD_COUNT; i++) { + PIDBuilder pidBuilder = new PIDBuilder((long) i).validPrefix().validSuffix(); + builders.add(new PIDRecordBuilder(pidBuilder, (long) i).completeProfile()); + } + + // Connect all builders + PIDRecordBuilder.connectRecordBuilders(null, null, false, + builders.toArray(new PIDRecordBuilder[0])); + + List records = new ArrayList<>(); + for (PIDRecordBuilder builder : builders) { + records.add(builder.build()); + } + + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()); + + assertEquals(LARGE_RECORD_COUNT, knownPidsDao.count()); + } + + @Test + @DisplayName("Test records with external references") + void testRecordsWithExternalReferences() throws Exception { + PIDRecord record = new PIDRecordBuilder() + .completeProfile() + .build(); + + // Add external reference + record.addEntry("21.T11148/d0773859091aeb451528", "externalRef", "external/pid/reference"); + + List records = List.of(record); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()); + + assertEquals(1, knownPidsDao.count()); + } + + @Test + @DisplayName("Test records with mixed connection types") + void testMixedConnectionTypes() throws Exception { + PIDRecordBuilder builder1 = new PIDRecordBuilder(null, 200L).completeProfile(); + PIDRecordBuilder builder2 = new PIDRecordBuilder(null, 201L).completeProfile(); + PIDRecordBuilder builder3 = new PIDRecordBuilder(null, 202L).completeProfile(); + + // Use different connection keys + builder1.addConnection("21.T11148/d0773859091aeb451528", false, builder2); + builder2.addConnection("21.T11148/4fe7cde52629b61e3b82", false, builder3); + builder3.addConnection(VALID_CONNECTION_KEYS[0], false, builder1); + + List records = List.of(builder1.build(), builder2.build(), builder3.build()); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()); + + assertEquals(3, knownPidsDao.count()); + } + + @Test + @DisplayName("Test records with null PIDs") + void testRecordsWithNullPids() throws Exception { + PIDRecord record = new PIDRecordBuilder() + .completeProfile() + .withPid(null) + .build(); + + List records = List.of(record); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()); + } + + @Test + @DisplayName("Test PID mapping persistence") + void testPidMappingPersistence() throws Exception { + PIDRecord record = new PIDRecordBuilder() + .completeProfile() + .build(); + + List records = List.of(record); + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()); + + assertEquals(1, knownPidsDao.count()); + + // Verify the PID was actually stored + String storedPid = knownPidsDao.findAll().get(0).getPid(); + assertNotNull(storedPid); + assertFalse(storedPid.isEmpty()); + } + + @Test + @DisplayName("Test PIDBuilder edge cases and combinations") + void testPIDBuilderEdgeCases() { + // Test various combinations of prefix and suffix methods + Long seed = 777L; + + // Test unauthorized prefix with valid suffix + PIDBuilder unauthorizedValid = new PIDBuilder(seed) + .unauthorizedPrefix() + .validSuffix(); + String unauthorizedValidPid = unauthorizedValid.build(); + assertTrue(unauthorizedValidPid.startsWith("0.NA/")); + + // Test empty prefix with empty suffix + PIDBuilder emptyEmpty = new PIDBuilder(seed) + .emptyPrefix() + .emptySuffix(); + String emptyEmptyPid = emptyEmpty.build(); + assertEquals("/", emptyEmptyPid); + + // Test invalid characters combinations + PIDBuilder invalidCombination = new PIDBuilder(seed) + .invalidCharactersPrefix() + .invalidCharactersSuffix(); + String invalidPid = invalidCombination.build(); + assertNotNull(invalidPid); + assertTrue(invalidPid.contains("/")); + + // Test custom prefix with custom suffix + PIDBuilder customBoth = new PIDBuilder(seed) + .withPrefix("test.prefix") + .withSuffix("test-suffix"); + String customPid = customBoth.build(); + assertEquals("test.prefix/test-suffix", customPid); + } + + @Test + @DisplayName("Test PIDRecordBuilder with various invalid configurations") + void testPIDRecordBuilderInvalidConfigurations() throws Exception { + // Test record with invalid keys + PIDRecord invalidKeysRecord = new PIDRecordBuilder() + .invalidKeys(5) + .build(); + + assertTrue(invalidKeysRecord.getEntries().size() >= 5); + + // Test record with invalid values for specific keys + PIDRecord invalidSpecificRecord = new PIDRecordBuilder() + .completeProfile() + .invalidValues(2, "21.T11148/397d831aa3a9d18eb52c", "21.T11148/8074aed799118ac263ad") + .build(); + + assertNotNull(invalidSpecificRecord); + + // Test empty record + PIDRecord emptyRecord = new PIDRecordBuilder() + .emptyRecord() + .build(); + + assertEquals(0, emptyRecord.getEntries().size()); + + // Submit invalid records to test API response + List invalidRecords = List.of(invalidKeysRecord); + String jsonContent = mapper.writeValueAsString(invalidRecords); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + } + + @Test + @DisplayName("Test builder chaining and method combinations") + void testBuilderChainingCombinations() { + Long seed = 888L; + + // Test complex PIDBuilder chaining + PIDBuilder complexBuilder = new PIDBuilder() + .withSeed(seed) + .validPrefix() + .validSuffix() + .withPrefix("chained.prefix") + .withSuffix("chained-suffix"); + + String complexPid = complexBuilder.build(); + assertEquals("chained.prefix/chained-suffix", complexPid); + + // Test complex PIDRecordBuilder chaining + PIDRecordBuilder complexRecordBuilder = new PIDRecordBuilder() + .withSeed(seed) + .completeProfile() + .withPid("custom/pid") + .invalidValues(1, "21.T11148/397d831aa3a9d18eb52c") + .invalidKeys(1); + + PIDRecord complexRecord = complexRecordBuilder.build(); + assertEquals("custom/pid", complexRecord.getPid()); + assertTrue(complexRecord.getEntries().size() > 0); + + // Test cloning and modification + PIDRecordBuilder cloned = complexRecordBuilder.clone(); + cloned.withPid("different/pid"); + + assertNotEquals(complexRecordBuilder.build().getPid(), cloned.build().getPid()); + } +} \ No newline at end of file diff --git a/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java b/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java index 8deabaf6..193453b9 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java +++ b/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java @@ -48,7 +48,7 @@ public PIDRecordBuilder() { * @param pidBuilder PID builder to use to generate a PID for the record */ public PIDRecordBuilder(PIDBuilder pidBuilder) { - this(pidBuilder, new Random().nextLong()); + this(pidBuilder, null); } /** @@ -58,46 +58,46 @@ public PIDRecordBuilder(PIDBuilder pidBuilder) { * @param seed seed for the random generator */ public PIDRecordBuilder(PIDBuilder pidBuilder, Long seed) { - this.seed = seed; - this.random = new Random(seed); + this.seed = seed != null ? seed : new Random().nextLong(); + this.random = new Random(this.seed); this.record = new PIDRecord(); - if (pidBuilder != null) { - this.record.setPid(pidBuilder.build()); - } else { - this.record.setPid(new PIDBuilder(seed).validPrefix().validSuffix().build()); + + if (pidBuilder == null) { + pidBuilder = new PIDBuilder(this.seed).validPrefix().validSuffix(); } + this.record.setPid(pidBuilder.build()); } /** * This function connects multiple PIDRecordBuilders with each other. The builders are connected in a fully meshed way. * - * @param a_to_b_key The key to use for the connection from A to B (forwards) - * @param b_to_a_key The key to use for the connection from B to A (backwards) - * @param allowDuplicateRelations If true, duplicate relations are allowed in the record, so that + * @param a_to_b_key The key to use for the connection from A to B (forwards). + * If null, the default key "21.T11148/d0773859091aeb451528" (hasMetadata) is used. + * @param b_to_a_key The key to use for the connection from B to A (backwards). + * If null, the default key "21.T11148/4fe7cde52629b61e3b82" (isMetadataFor) is used. + * @param allowDuplicateRelations If true, duplicate relations are allowed in the record, so that multiple connections can be established between the same builders. * @param builders A list of PIDRecordBuilders to connect * @return A list of connected PIDRecordBuilders * @throws IllegalArgumentException If less than two builders are given */ public static List connectRecordBuilders(String a_to_b_key, String b_to_a_key, boolean allowDuplicateRelations, PIDRecordBuilder... builders) throws IllegalArgumentException { if (builders.length < 2) { - throw new IllegalArgumentException("At least two builders are needed to connect them."); + throw new IllegalArgumentException("At least two builders are required for connection"); } - if (a_to_b_key == null) a_to_b_key = HAS_METADATA_KEY; - if (b_to_a_key == null) b_to_a_key = IS_METADATA_FOR_KEY; - - List connectedBuilders = new ArrayList<>(); - // Connect the builders in a fully meshed way - for (int i = 0; i < builders.length; i++) { // Iterate over all builders - PIDRecordBuilder builder = builders[i]; // The builder to connect to all other builders to - for (int j = 0; j < builders.length; j++) { // Iterate over all builders - if (i != j) { // Do not self-connect the builder - builder.addConnection(a_to_b_key, !allowDuplicateRelations, builders[j]); // Connect builder to other builder - builders[j].addConnection(b_to_a_key, !allowDuplicateRelations, builder); // Connect other builder to builder + + String forwardKey = a_to_b_key != null ? a_to_b_key : HAS_METADATA_KEY; + String backwardKey = b_to_a_key != null ? b_to_a_key : IS_METADATA_FOR_KEY; + + for (int i = 0; i < builders.length; i++) { + for (int j = 0; j < builders.length; j++) { + if (i != j) { + builders[i].addConnection(forwardKey, false, builders[j]); + builders[j].addConnection(backwardKey, false, builders[i]); } } - connectedBuilders.add(builder); // Add the connected builder to the list } - return connectedBuilders; // Return the list of connected builders + + return Arrays.asList(builders); } /** @@ -145,11 +145,12 @@ public PIDRecordBuilder withPid(String pid) { */ public PIDRecordBuilder addConnection(String key, boolean replaceIdentical, PIDRecordBuilder... builders) throws IllegalArgumentException { if (builders.length == 0) { - throw new IllegalArgumentException("At least one builder is needed to connect."); + throw new IllegalArgumentException("At least one builder is required for connection"); } for (PIDRecordBuilder builder : builders) { this.addNotDuplicate(key, builder.record.getPid(), "connectedPID", replaceIdentical); } + return this; } @@ -191,16 +192,12 @@ public PIDRecordBuilder completeProfile() { public PIDRecordBuilder incompleteProfile() { this.addNotDuplicate(PROFILE_KEY, "21.T11148/301c6f04763a16f0f72a", "KernelInformationProfile", true); - Set containedKeys = new HashSet<>(); this.record.getEntries().keySet().forEach(key -> { if (KEYS_IN_PROFILE.contains(key)) { - containedKeys.add(key); + this.record.removeAllValuesOf(key); } }); - if (!containedKeys.isEmpty()) { - int randomIndex = random.nextInt(containedKeys.size()); - this.record.removeAllValuesOf(containedKeys.toArray()[randomIndex].toString()); - } + return this; } @@ -282,11 +279,12 @@ public PIDRecordBuilder nullRecord() { * @param value value of the entry * @param replace if true, replace the value of the entry if it already exists, even if it is a list of values */ - private void addNotDuplicate(String key, String value, String name, boolean replace) { + public PIDRecordBuilder addNotDuplicate(String key, String value, String name, boolean replace) { if (this.record.getEntries().containsKey(key) && replace) { this.record.removeAllValuesOf(key); } this.record.addEntry(key, name, value); + return this; } /** From edb1dec9685fc999904520376504d88574c1febc Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Wed, 4 Jun 2025 08:37:30 +0200 Subject: [PATCH 085/108] added tests Signed-off-by: Maximilian Inckmann --- .../pit/web/impl/TypingRESTResourceImpl.java | 7 + .../pit/web/ConnectedPIDsTest.java | 206 +++++++++++++++++- .../datamanager/pit/web/PIDRecordBuilder.java | 2 +- 3 files changed, 211 insertions(+), 4 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index b32b0ba0..9487ffeb 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -103,6 +103,13 @@ public ResponseEntity> createPIDs( LOG.warn("No records provided for PID creation."); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Collections.emptyList()); } + if (rec.size() == 1) { + // If only one record is provided, we can use the single record creation method. + LOG.info("Only one record provided. Using single record creation method."); + var result = createPID(rec.getFirst(), dryrun, request, response, uriBuilder); + // Return the single record in a list + return ResponseEntity.status(result.getStatusCode()).headers(result.getHeaders()).body(Collections.singletonList(result.getBody())); + } Instant startTime = Instant.now(); LOG.info("Creating PIDs for {} records.", rec.size()); String prefix = this.typingService.getPrefix().orElseThrow(() -> new IOException("No prefix configured.")); diff --git a/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java b/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java index 0a552db6..3e6effa3 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java @@ -1,3 +1,4 @@ + /* * Copyright (c) 2025 Karlsruhe Institute of Technology. * @@ -56,7 +57,7 @@ class ConnectedPIDsTest { private static final Instant NOW = Instant.now().plus(1, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS); private static final Instant YESTERDAY = NOW.minus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); private static final Instant TOMORROW = NOW.plus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); - private static final int RECORD_COUNT = 16; // FIXME: Use in all tests that require multiple records + private static final int RECORD_COUNT = 16; private static final int LARGE_RECORD_COUNT = 200; // Valid DTR keys for testing connections @@ -214,7 +215,7 @@ void testPIDRecordBuilderAllMethods() { assertTrue(profileRecord.getEntries().size() > 0); // Test incompleteProfile method - PIDRecordBuilder incompleteBuilder = new PIDRecordBuilder().completeProfile().incompleteProfile();//FIXME: something goes wrong here (ConcurrentModificationException) + PIDRecordBuilder incompleteBuilder = new PIDRecordBuilder().incompleteProfile(); PIDRecord incompleteRecord = incompleteBuilder.build(); assertNotNull(incompleteRecord); @@ -520,7 +521,7 @@ void testRecordsWithMissingEntries() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonContent)) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isBadRequest()); //FIXME: This should fail with 400 but currently does not. + .andExpect(MockMvcResultMatchers.status().isBadRequest()); } @Test @@ -753,4 +754,203 @@ void testBuilderChainingCombinations() { assertNotEquals(complexRecordBuilder.build().getPid(), cloned.build().getPid()); } + + @Test + @DisplayName("Test multiple record creation with using RECORD_COUNT constant") + void testMultipleRecordCreationWithRecordCount() throws Exception { + List records = new ArrayList<>(); + + // Create multiple records using RECORD_COUNT constant + for (int i = 0; i < RECORD_COUNT; i++) { + PIDBuilder pidBuilder = new PIDBuilder((long) i).validPrefix().validSuffix(); + PIDRecord record = new PIDRecordBuilder(pidBuilder, (long) i) + .completeProfile() + .build(); + records.add(record); + } + + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()); + + assertEquals(RECORD_COUNT, knownPidsDao.count()); + } + + @Test + @DisplayName("Test connected records with RECORD_COUNT") + void testConnectedRecordsWithRecordCount() throws Exception { + List builders = new ArrayList<>(); + + // Create RECORD_COUNT builders for connection testing + for (int i = 0; i < RECORD_COUNT; i++) { + PIDBuilder pidBuilder = new PIDBuilder((long) i).validPrefix().validSuffix(); + builders.add(new PIDRecordBuilder(pidBuilder, (long) i).completeProfile()); + } + + // Connect all builders + PIDRecordBuilder.connectRecordBuilders(null, null, false, + builders.toArray(new PIDRecordBuilder[0])); + + List records = new ArrayList<>(); + for (PIDRecordBuilder builder : builders) { + records.add(builder.build()); + } + + String jsonContent = mapper.writeValueAsString(records); + + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isCreated()); + + assertEquals(RECORD_COUNT, knownPidsDao.count()); + } + + @Test + @DisplayName("Test partial failure and rollback scenario") + void testPartialFailureAndRollback() throws Exception { + List records = new ArrayList<>(); + + // Create some valid records + for (int i = 0; i < 3; i++) { + PIDRecord validRecord = new PIDRecordBuilder() + .completeProfile() + .build(); + records.add(validRecord); + } + + // Add records that should cause validation or creation failures + // Record with incomplete profile (missing required entries) + PIDRecord incompleteRecord = new PIDRecordBuilder() + .incompleteProfile() + .build(); + records.add(incompleteRecord); + + // Record with invalid keys + PIDRecord invalidKeysRecord = new PIDRecordBuilder() + .invalidKeys(3) + .build(); + records.add(invalidKeysRecord); + + // Record with invalid values for specific keys + PIDRecord invalidValuesRecord = new PIDRecordBuilder() + .completeProfile() + .invalidValues(2, "21.T11148/397d831aa3a9d18eb52c") + .build(); + records.add(invalidValuesRecord); + + String jsonContent = mapper.writeValueAsString(records); + + // This should result in a server error due to failed validation/creation + // and the rollback mechanism should be triggered + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + + // Verify that no PIDs were persisted due to rollback + assertEquals(0, knownPidsDao.count()); + } + + @Test + @DisplayName("Test batch with mixed valid and invalid records for rollback coverage") + void testBatchWithMixedRecordsForRollbackCoverage() throws Exception { + List records = new ArrayList<>(); + + // Create valid records that would initially succeed + for (int i = 0; i < RECORD_COUNT / 2; i++) { + PIDRecord record = new PIDRecordBuilder() + .withSeed((long) i) + .completeProfile() + .build(); + records.add(record); + } + + // Add records with various failure scenarios to trigger rollback + + // Empty record (no entries) + PIDRecord emptyRecord = new PIDRecordBuilder() + .emptyRecord() + .build(); + records.add(emptyRecord); + + // Record with null PID and incomplete profile + PIDRecord nullPidRecord = new PIDRecordBuilder() + .withPid(null) + .incompleteProfile() + .build(); + records.add(nullPidRecord); + + // Record with completely invalid data + PIDRecord invalidRecord = new PIDRecordBuilder() + .invalidKeys(5) + .invalidValues(3) + .build(); + records.add(invalidRecord); + + String jsonContent = mapper.writeValueAsString(records); + + // Expect server error due to validation failures + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + + // Verify rollback: no records should be persisted + assertEquals(0, knownPidsDao.count()); + } + + @Test + @DisplayName("Test record creation failure scenarios with duplicate PIDs in batch") + void testRecordCreationFailureWithDuplicatePids() throws Exception { + List records = new ArrayList<>(); + + // Create multiple valid records first + for (int i = 0; i < RECORD_COUNT / 4; i++) { + PIDRecord record = new PIDRecordBuilder() + .withSeed((long) i) + .completeProfile() + .build(); + records.add(record); + } + + // Add duplicate PIDs to trigger failure + String duplicatePid = "sandboxed/duplicate-test-pid"; + + PIDRecord record1 = new PIDRecordBuilder() + .completeProfile() + .withPid(duplicatePid) + .build(); + records.add(record1); + + PIDRecord record2 = new PIDRecordBuilder() + .completeProfile() + .withPid(duplicatePid) + .build(); + records.add(record2); + + String jsonContent = mapper.writeValueAsString(records); + + // This should fail due to duplicate PIDs and trigger rollback + this.mockMvc + .perform(post("/api/v1/pit/pids") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + + // Verify no records were persisted + assertEquals(0, knownPidsDao.count()); + } } \ No newline at end of file diff --git a/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java b/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java index 193453b9..b8ff076f 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java +++ b/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java @@ -190,7 +190,7 @@ public PIDRecordBuilder completeProfile() { * @return this builder */ public PIDRecordBuilder incompleteProfile() { - this.addNotDuplicate(PROFILE_KEY, "21.T11148/301c6f04763a16f0f72a", "KernelInformationProfile", true); + this.addNotDuplicate(PROFILE_KEY, "21.T11148/b9b76f887845e32d29f7", "KernelInformationProfile", true); this.record.getEntries().keySet().forEach(key -> { if (KEYS_IN_PROFILE.contains(key)) { From 44c98f24ac593287e09387aa96c2419ca1a5c276 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 10 Jun 2025 13:06:57 +0200 Subject: [PATCH 086/108] fix(EmbeddedStrictValidatorStrategy): await profile validation futures properly --- .../impl/EmbeddedStrictValidatorStrategy.java | 20 ++++++++++--------- .../pit/typeregistry/RegisteredProfile.java | 9 ++++++--- .../web/ExplicitValidationParametersTest.java | 7 +++++-- .../pit/web/RestWithHandleProtocolTest.java | 11 +++++----- .../pit/web/RestWithInMemoryTest.java | 5 ++++- 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java index 9d08d768..19145874 100644 --- a/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java +++ b/src/main/java/edu/kit/datamanager/pit/pitservice/impl/EmbeddedStrictValidatorStrategy.java @@ -67,17 +67,19 @@ public void validate(PIDRecord pidRecord) return attributeInfo; })) // resolve profiles and apply their validation - .map(attributeInfoFuture -> attributeInfoFuture.thenApply(attributeInfo -> { + .map(attributeInfoFuture -> attributeInfoFuture.thenCompose(attributeInfo -> { boolean indicatesProfileValue = this.profileKeys.contains(attributeInfo.pid()); - if (indicatesProfileValue) { - Arrays.stream(pidRecord.getPropertyValues(attributeInfo.pid())) - .map(this.typeRegistry::queryAsProfile) - .forEach(registeredProfileFuture -> registeredProfileFuture.thenApply(registeredProfile -> { - registeredProfile.validateAttributes(pidRecord, this.alwaysAcceptAdditionalAttributes); - return registeredProfile; - })); + if (!indicatesProfileValue) { + return CompletableFuture.completedFuture(attributeInfo); } - return attributeInfo; + CompletableFuture[] profileFutures = Arrays.stream(pidRecord.getPropertyValues(attributeInfo.pid())) + .map(this.typeRegistry::queryAsProfile) + .map(registeredProfileFuture -> registeredProfileFuture.thenAccept( + registeredProfile -> { + registeredProfile.validateAttributes(pidRecord, this.alwaysAcceptAdditionalAttributes); + })) + .toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(profileFutures).thenApply(v -> attributeInfo); })) .toList(); diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java index 44710815..661e1907 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/RegisteredProfile.java @@ -13,10 +13,13 @@ public record RegisteredProfile( boolean allowAdditionalAttributes, ImmutableList attributes ) { - - public void validateAttributes(PIDRecord pidRecord, boolean alwaysAllowAdditionalAttributes) { + public void validateAttributes( + PIDRecord pidRecord, + boolean alwaysAllowAdditionalAttributes + ) throws RecordValidationException + { Set attributesNotDefinedInProfile = pidRecord.getPropertyIdentifiers().stream() - .filter(recordKey -> attributes.items().stream().anyMatch( + .filter(recordKey -> attributes.items().stream().noneMatch( profileAttribute -> Objects.equals(profileAttribute.pid(), recordKey))) .collect(Collectors.toSet()); diff --git a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java index 64bf081e..360893e1 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ExplicitValidationParametersTest.java @@ -234,8 +234,11 @@ void testRecordWithInvalidValue() throws Exception { @Test void testRecordWithAdditionalAttribute() throws Exception { - PIDRecord r = new PIDRecord(); - r.addEntry("21.T11148/076759916209e5d62bd5", "for Testing", "21.T11148/301c6f04763a16f0f72a"); + PIDRecord r = ApiMockUtils.getSomePidRecordInstance(); + r.addEntry( + "21.T11969/86963861a2b249a83b93", + "additional attribute", + "{\"image-context-name\": \"itsa'me!\", \"image-context-uri\": \"https://example.com/mario\"}"); this.mockMvc .perform( post("/api/v1/pit/pid/") diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java index d5fcd69b..e1ab8b73 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithHandleProtocolTest.java @@ -91,6 +91,7 @@ void resolveSomething() throws Exception { @Test void testDryrunUpdateWithPidGiven() throws Exception { + // Get a real record String url = "/api/v1/pit/pid/21.11152/474a4b1c-de93-4d4a-b33d-1d32d63baf4b"; MockHttpServletResponse response = this.mockMvc.perform( get(url).param("validation", "false") @@ -100,14 +101,14 @@ void testDryrunUpdateWithPidGiven() throws Exception { .andReturn() .getResponse(); String etag = response.getHeader("ETag"); + + // Parse so we can edit the record easily PIDRecord record = mapper.readValue(response.getContentAsString(), PIDRecord.class); + // fix record, it is actually invalid... record.removeAllValuesOf("URL"); - // fix possible issue with this type in current state of type api - record.removeAllValuesOf("21.T11148/2f314c8fe5fb6a0063a8"); - String licenseUrl = "21.T11969/e0efc41346cda4ba84ca"; - record.removeAllValuesOf(licenseUrl); - record.addEntry(licenseUrl, "https://cdla.dev/permissive-2-0/"); + + // perform dryrun update this.mockMvc.perform( put(url) .param("dryrun", "true") diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java index c341e05c..0d9625ab 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java @@ -192,7 +192,10 @@ void testNontypeRecord() throws Exception { @Test void testRecordWithAdditionalAttribute() throws Exception { PIDRecord r = new PIDRecord(); - r.addEntry("21.T11148/076759916209e5d62bd5", "for Testing", "21.T11148/301c6f04763a16f0f72a"); + r.addEntry( + "21.T11969/86963861a2b249a83b93", + "additional attribute", + "{\"image-context-name\": \"itsa'me!\", \"image-context-uri\": \"https://example.com/mario\"}"); MvcResult result = this.mockMvc .perform( post("/api/v1/pit/pid/") From e02b058af981a6976da8f00c7cd449fe7011eb88 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 10 Jun 2025 14:23:34 +0200 Subject: [PATCH 087/108] test(RestWithInMemoryTest): add test for record with only a profile --- .../pit/web/RestWithInMemoryTest.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java b/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java index 0d9625ab..a78ec748 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/RestWithInMemoryTest.java @@ -149,6 +149,26 @@ void testCreateEmptyRecord() throws Exception { assertEquals(0, this.knownPidsDao.count()); } + @Test + @DisplayName("Test record with only a profile, no entries.") + void testRecordWithOnlyProfile() throws Exception { + PIDRecord incompleteRecord = new PIDRecord(); + incompleteRecord.addEntry( + "21.T11148/076759916209e5d62bd5", + "kernelInformationProfile", + "21.T11148/b9b76f887845e32d29f7"); + + String jsonContent = new ObjectMapper().writeValueAsString(incompleteRecord); + + this.mockMvc + .perform(post("/api/v1/pit/pid/") + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding("utf-8") + .content(jsonContent)) + .andDo(MockMvcResultHandlers.print()) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + } + @Test @DisplayName("Testing PID Records with usual/larger size, with the InMemory PID system.") void testExtensiveRecord() throws Exception { From 22011d4f59dab7eec6628abce0a2b87e84bd4173 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 11 Jun 2025 14:33:52 +0200 Subject: [PATCH 088/108] fix: correct message formatting in ExternalServiceException --- .../kit/datamanager/pit/common/ExternalServiceException.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/common/ExternalServiceException.java b/src/main/java/edu/kit/datamanager/pit/common/ExternalServiceException.java index c2ade458..298c35ce 100644 --- a/src/main/java/edu/kit/datamanager/pit/common/ExternalServiceException.java +++ b/src/main/java/edu/kit/datamanager/pit/common/ExternalServiceException.java @@ -6,12 +6,12 @@ public class ExternalServiceException extends ResponseStatusException { - private static final String MESSAGE_TERM_SERVICE = "Service"; + private static final String MESSAGE_TERM_SERVICE = "Service "; private static final long serialVersionUID = 1L; private static final HttpStatus HTTP_STATUS = HttpStatus.SERVICE_UNAVAILABLE; public ExternalServiceException(String serviceName) { - super(HTTP_STATUS, "Service " + serviceName + " not available."); + super(HTTP_STATUS, MESSAGE_TERM_SERVICE + serviceName + " not available."); } public ExternalServiceException(String serviceName, Throwable e) { From 45646df0c3d81defb9d11d8f931b3ca8c827a946 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 11 Jun 2025 14:45:23 +0200 Subject: [PATCH 089/108] fix: filter out schemas with errors, as they may accept anything --- .../java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java index 4e4a3527..93059a5c 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java @@ -26,6 +26,7 @@ public record AttributeInfo( ) { public boolean validate(String value) { return this.jsonSchema().stream() + .filter(schemaInfo -> schemaInfo.error() == null) .map(SchemaInfo::schema) .filter(Objects::nonNull) .anyMatch(schema -> validate(schema, value)); From 19309385d07f0c544c1d40a7bf139b4330a2e267 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 11 Jun 2025 16:03:00 +0200 Subject: [PATCH 090/108] feat: add logging for validation errors and exceptions in AttributeInfo --- .../datamanager/pit/typeregistry/AttributeInfo.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java index 93059a5c..b3c1f22f 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java @@ -5,6 +5,8 @@ import com.networknt.schema.ValidationMessage; import edu.kit.datamanager.pit.Application; import edu.kit.datamanager.pit.typeregistry.schema.SchemaInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.Collection; import java.util.Objects; @@ -24,6 +26,8 @@ public record AttributeInfo( String typeName, Collection jsonSchema ) { + private static final Logger log = LoggerFactory.getLogger(AttributeInfo.class); + public boolean validate(String value) { return this.jsonSchema().stream() .filter(schemaInfo -> schemaInfo.error() == null) @@ -39,10 +43,13 @@ private boolean validate(JsonSchema schema, String value) { // By default, since Draft 2019-09, the format keyword only generates annotations and not assertions executionContext.getExecutionConfig().setFormatAssertionsEnabled(true); }); + if (!errors.isEmpty()) { + log.warn("Validation errors for value '{}': {}", value, errors); + } return errors.isEmpty(); - // TODO we could catch the validation errors here in order to return them to the user } catch (Exception e) { + log.error("Exception during validation for value '{}': {}", value, e.getMessage(), e); return false; } } -} +} \ No newline at end of file From 262c25a81d0b4d674de30622e2f67281b7dbaeb7 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 11 Jun 2025 16:04:20 +0200 Subject: [PATCH 091/108] feat: add logging for schema retrieval errors in SchemaSetGenerator --- .../pit/typeregistry/schema/SchemaSetGenerator.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java index 166fae87..8cabd6d0 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java @@ -4,6 +4,8 @@ import com.github.benmanes.caffeine.cache.Caffeine; import edu.kit.datamanager.pit.Application; import edu.kit.datamanager.pit.configuration.ApplicationProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.time.Duration; import java.util.Set; @@ -12,6 +14,7 @@ import java.util.stream.Collectors; public class SchemaSetGenerator { + private static final Logger LOGGER = LoggerFactory.getLogger(SchemaSetGenerator.class); protected final Set GENERATORS; protected final AsyncLoadingCache> CACHE; @@ -28,6 +31,15 @@ public SchemaSetGenerator(ApplicationProperties props) { .expireAfterWrite(props.getCacheExpireAfterWriteLifetime(), TimeUnit.MINUTES) .buildAsync(attributePid -> GENERATORS.stream() .map(schemaGenerator -> schemaGenerator.generateSchema(attributePid)) + .peek(schemaInfo -> { + if (schemaInfo.error() != null) { + LOGGER.warn( + "Error when retrieving schema for attribute ({}): {}", + attributePid, + schemaInfo.error().getMessage()); + } + } + ) .collect(Collectors.toSet()) ); } From 16423b235e8ff1e927c964ba00c5fbc3d3d60ed9 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 11 Jun 2025 16:21:25 +0200 Subject: [PATCH 092/108] refactor: remove obsolete schema query test in TypeApiTest The type-api was fixed and in addition, the dtr-test instance has an ill-formed schema which is not accepted by our new validation library. Therefore, this test does not make sense anymore as anything else is covered by the other tests. --- .../pit/typeregistry/impl/TypeApiTest.java | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java index 205c19af..a69af98e 100644 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/impl/TypeApiTest.java @@ -2,15 +2,12 @@ import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.typeregistry.AttributeInfo; -import edu.kit.datamanager.pit.typeregistry.schema.SchemaInfo; import edu.kit.datamanager.pit.typeregistry.schema.SchemaSetGenerator; import org.junit.jupiter.api.Test; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; -import java.util.Optional; -import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -50,22 +47,4 @@ void queryAttributeInfoOfComplexType() { assertTrue(info.name().contains("checksum")); assertEquals("PID-InfoType", info.typeName()); } - - /* ================== TESTING INTERNALS ================== */ - - @Test - void querySchemaOfComplexType() { - // NOTE The new Type-API currently returns a malformed schema for the - // checksum type in dtr-test. This test ensures that we at least get - // the legacy schema in this case. If this test breaks, we either - // have no schema, or the type-api is fixed. - Set s = dtr.querySchemas(PID_COMPLEX_TYPE_CHECKSUM_DTRTEST); - assertEquals(2, s.size()); - Optional result = s.stream() - .filter(schemaInfo -> schemaInfo.schema() != null) - .filter(schemaInfo -> schemaInfo.error() == null) - .filter(schemaInfo -> schemaInfo.origin().contains("dtr-test")) - .findAny(); - assertTrue(result.isPresent()); - } } \ No newline at end of file From bc39406d7e74aa68b40666c3a6ce2bbea543d8a8 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 11 Jun 2025 16:36:53 +0200 Subject: [PATCH 093/108] fix(test): update checksum validation examples to new validator --- .../typeregistry/schema/SchemaSetGeneratorTest.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java index 7322f36d..cc4a8737 100644 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java @@ -33,12 +33,16 @@ private static Stream typeWithExamplesAndCounterexamples() { // checksum Arguments.of( "21.T11148/92e200311a56800b3e47", - "{ \"sha256sum\": \"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\" }", - "\"c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\""), + // This is how the type way meant to be used from the beginning, + // and what the new schema generation service generates. + "\"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\"", + // this was possible before changing the validator, + // as it was interpreting ill-formed JSON-schema with a lot of tolerance. + "{ \"sha256sum\": \"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\" }"), // checksum Arguments.of( "21.T11148/92e200311a56800b3e47", - "\"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\"", + "\"c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\"", "\"not a checksum\""), // URI with schema making use of "format" to specify an uri Arguments.of("21.T11969/cb371c93c5aa0e62198e", "\"https://example.com\"", "This is not a URI") @@ -49,6 +53,7 @@ private static Stream typeWithExamplesAndCounterexamples() { @MethodSource("typeWithExamplesAndCounterexamples") void testExampleAndCounterexample(String typePid, String example, String counterexample) { Set schemaInfos = generator.generateFor(typePid).join(); + assertFalse(schemaInfos.isEmpty(), "No schemas found for type pid: " + typePid); AttributeInfo attributeInfo = new AttributeInfo(typePid, "name", "typeName", schemaInfos); assertTrue(attributeInfo.validate(example)); assertFalse(attributeInfo.validate(counterexample)); From 4484db7ef995ef696333f3da95443d9a1bc49e6b Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 11 Jun 2025 18:09:56 +0200 Subject: [PATCH 094/108] fix: attribute value parsing and corresponding tests --- .../pit/typeregistry/AttributeInfo.java | 24 +++++- .../pit/typeregistry/AttributeInfoTest.java | 85 +++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/test/java/edu/kit/datamanager/pit/typeregistry/AttributeInfoTest.java diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java index b3c1f22f..bbad4956 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java @@ -1,6 +1,8 @@ package edu.kit.datamanager.pit.typeregistry; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.TextNode; import com.networknt.schema.JsonSchema; import com.networknt.schema.ValidationMessage; import edu.kit.datamanager.pit.Application; @@ -38,7 +40,7 @@ public boolean validate(String value) { private boolean validate(JsonSchema schema, String value) { try { - JsonNode toValidate = Application.jsonObjectMapper().readTree(value); + JsonNode toValidate = valueToJsonNode(value); Set errors = schema.validate(toValidate, executionContext -> { // By default, since Draft 2019-09, the format keyword only generates annotations and not assertions executionContext.getExecutionConfig().setFormatAssertionsEnabled(true); @@ -52,4 +54,24 @@ private boolean validate(JsonSchema schema, String value) { return false; } } + + /** + * Converts the given value to a JsonNode. + * + * @param value the value to convert + * @return a JsonNode representation of the value + */ + public static JsonNode valueToJsonNode(String value) { + JsonNode toValidate; + if (value.isBlank()) { + return new TextNode(value); + } + try { + toValidate = Application.jsonObjectMapper().readTree(value); + } catch (JsonProcessingException e) { + log.warn("Failed to parse value '{}' as JSON, treating it as a plain text node.", value); + toValidate = new TextNode(value); + } + return toValidate; + } } \ No newline at end of file diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/AttributeInfoTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/AttributeInfoTest.java new file mode 100644 index 00000000..ad9fedb9 --- /dev/null +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/AttributeInfoTest.java @@ -0,0 +1,85 @@ +package edu.kit.datamanager.pit.typeregistry; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class AttributeInfoTest { + @Test + void valueToJsonNode_givenInteger_returnsNumberNode() { + var numberNode = AttributeInfo.valueToJsonNode("42"); + assertTrue(numberNode.isNumber()); + assertEquals(42, numberNode.numberValue()); + } + + @Test + void valueToJsonNode_givenString_returnsTextNode() { + var textNode = AttributeInfo.valueToJsonNode("Hello, World!"); + assertTrue(textNode.isTextual()); + assertEquals("Hello, World!", textNode.textValue()); + } + + @Test + void valueToJsonNode_givenBoolean_returnsBooleanNode() { + var booleanNode = AttributeInfo.valueToJsonNode("true"); + assertTrue(booleanNode.isBoolean()); + assertTrue(booleanNode.booleanValue()); + + booleanNode = AttributeInfo.valueToJsonNode("false"); + assertTrue(booleanNode.isBoolean()); + assertFalse(booleanNode.booleanValue()); + } + + @Test + void valueToJsonNode_givenNull_returnsNullNode() { + var nullNode = AttributeInfo.valueToJsonNode("null"); + assertTrue(nullNode.isNull()); + } + + @Test + void valueToJsonNode_givenJsonString_returnsJsonObject() { + var jsonString = "{\"key\": \"value\"}"; + var jsonNode = AttributeInfo.valueToJsonNode(jsonString); + assertTrue(jsonNode.isObject()); + assertEquals("value", jsonNode.get("key").textValue()); + } + + @Test + void valueToJsonNode_givenDecimalNumber_returnsNumberNode() { + var numberNode = AttributeInfo.valueToJsonNode("42.5"); + assertTrue(numberNode.isNumber()); + assertEquals(42.5, numberNode.doubleValue()); + } + + @Test + void valueToJsonNode_givenJsonArray_returnsArrayNode() { + var jsonArray = "[1, \"test\", true]"; + var arrayNode = AttributeInfo.valueToJsonNode(jsonArray); + assertTrue(arrayNode.isArray()); + assertEquals(3, arrayNode.size()); + assertTrue(arrayNode.get(0).isNumber()); + assertTrue(arrayNode.get(1).isTextual()); + assertTrue(arrayNode.get(2).isBoolean()); + } + + @Test + void valueToJsonNode_givenEmptyString_returnsTextNode() { + var node = AttributeInfo.valueToJsonNode(""); + assertTrue(node.isTextual()); + assertEquals("", node.textValue()); + } + + @Test + void valueToJsonNode_givenWhitespaceOnly_returnsTextNode() { + var node = AttributeInfo.valueToJsonNode(" "); + assertTrue(node.isTextual()); + assertEquals(" ", node.textValue()); + } + + @Test + void valueToJsonNode_givenLargeNumber_returnsNumberNode() { + var numberNode = AttributeInfo.valueToJsonNode("9223372036854775807"); // Long.MAX_VALUE + assertTrue(numberNode.isNumber()); + assertEquals(9223372036854775807L, numberNode.longValue()); + } +} \ No newline at end of file From 2eade02a9337ca696b303ec60a78c477e9ad0a43 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 11 Jun 2025 18:15:24 +0200 Subject: [PATCH 095/108] fix: schema presence check used isEmpty, meant isNull --- .../pit/typeregistry/schema/DtrTestSchemaGenerator.java | 2 +- .../pit/typeregistry/schema/TypeApiSchemaGenerator.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java index 3415d8be..2b88eeee 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java @@ -68,7 +68,7 @@ public SchemaInfo generateSchema(@NotNull String maybeTypePid) { .readTree(inputStream) .path("validationSchema"); schema = this.schemaFactory.getSchema(schemaDocument); - if (schema == null || schema.getSchemaNode().isEmpty()) { + if (schema == null || schema.getSchemaNode().isNull()) { throw new IOException("Could not create valid schema for %s from %s " .formatted(maybeTypePid, schemaDocument)); } diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java index 4dd88c58..daf69d05 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java @@ -66,7 +66,7 @@ public SchemaInfo generateSchema(@NotNull String maybeTypePid) { JsonNode schemaDocument = Application.jsonObjectMapper() .readTree(inputStream); schema = schemaFactory.getSchema(schemaDocument); - if (schema == null || schema.getSchemaNode().isEmpty()) { + if (schema == null || schema.getSchemaNode().isNull()) { throw new IOException("Could not create valid schema for %s from %s " .formatted(maybeTypePid, schemaDocument)); } From 97cb37249abbbf67df30ba3953ba7dddf9e303bd Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 11 Jun 2025 18:15:53 +0200 Subject: [PATCH 096/108] fix: improve logging message for schema retrieval errors --- .../pit/typeregistry/schema/SchemaSetGenerator.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java index 8cabd6d0..59912555 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGenerator.java @@ -34,7 +34,8 @@ public SchemaSetGenerator(ApplicationProperties props) { .peek(schemaInfo -> { if (schemaInfo.error() != null) { LOGGER.warn( - "Error when retrieving schema for attribute ({}): {}", + "Error when retrieving schema from {} for attribute ({}): {}", + schemaInfo.origin(), attributePid, schemaInfo.error().getMessage()); } From 7d55112e4f4ad682e925f457144ee17fcd7cc362 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 11 Jun 2025 18:33:55 +0200 Subject: [PATCH 097/108] Revert "fix(test): update checksum validation examples to new validator" This reverts commit bc39406d7e74aa68b40666c3a6ce2bbea543d8a8. --- .../typeregistry/schema/SchemaSetGeneratorTest.java | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java index cc4a8737..7322f36d 100644 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java @@ -33,16 +33,12 @@ private static Stream typeWithExamplesAndCounterexamples() { // checksum Arguments.of( "21.T11148/92e200311a56800b3e47", - // This is how the type way meant to be used from the beginning, - // and what the new schema generation service generates. - "\"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\"", - // this was possible before changing the validator, - // as it was interpreting ill-formed JSON-schema with a lot of tolerance. - "{ \"sha256sum\": \"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\" }"), + "{ \"sha256sum\": \"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\" }", + "\"c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\""), // checksum Arguments.of( "21.T11148/92e200311a56800b3e47", - "\"c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\"", + "\"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\"", "\"not a checksum\""), // URI with schema making use of "format" to specify an uri Arguments.of("21.T11969/cb371c93c5aa0e62198e", "\"https://example.com\"", "This is not a URI") @@ -53,7 +49,6 @@ private static Stream typeWithExamplesAndCounterexamples() { @MethodSource("typeWithExamplesAndCounterexamples") void testExampleAndCounterexample(String typePid, String example, String counterexample) { Set schemaInfos = generator.generateFor(typePid).join(); - assertFalse(schemaInfos.isEmpty(), "No schemas found for type pid: " + typePid); AttributeInfo attributeInfo = new AttributeInfo(typePid, "name", "typeName", schemaInfos); assertTrue(attributeInfo.validate(example)); assertFalse(attributeInfo.validate(counterexample)); From 4f4bf89cca4cc5e76e568cf2c4b0e4b169352694 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Wed, 11 Jun 2025 18:43:23 +0200 Subject: [PATCH 098/108] fix: enhance schema validation logging and null checks --- .../edu/kit/datamanager/pit/typeregistry/AttributeInfo.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java index bbad4956..2bfe77c1 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/AttributeInfo.java @@ -33,9 +33,9 @@ public record AttributeInfo( public boolean validate(String value) { return this.jsonSchema().stream() .filter(schemaInfo -> schemaInfo.error() == null) - .map(SchemaInfo::schema) - .filter(Objects::nonNull) - .anyMatch(schema -> validate(schema, value)); + .filter(schemaInfo -> schemaInfo.schema() != null) + .peek(schemaInfo -> log.warn("Found valid schema from {} to validate {} / {}.", schemaInfo.origin(), pid, value)) + .anyMatch(schemaInfo -> this.validate(schemaInfo.schema(), value)); } private boolean validate(JsonSchema schema, String value) { From 7aa7a0f6d07431becb18cac1c628e469e2db8292 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Thu, 12 Jun 2025 12:41:09 +0200 Subject: [PATCH 099/108] deps: update json-schema-validator dependency to version 1.5.7 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 5bedf90e..2ce22968 100644 --- a/build.gradle +++ b/build.gradle @@ -81,7 +81,7 @@ dependencies { implementation "org.springframework.data:spring-data-elasticsearch" // More flexibility when (de-)serializing json: - implementation(group: 'com.networknt', name: 'json-schema-validator', version: '1.5.5'); + implementation(group: 'com.networknt', name: 'json-schema-validator', version: '1.5.7'); implementation('org.apache.httpcomponents:httpclient:4.5.14') implementation('org.apache.httpcomponents:httpclient-cache:4.5.14') From f3d25c3d25d987fda31953b142de9b110b3afa55 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Thu, 12 Jun 2025 13:57:03 +0200 Subject: [PATCH 100/108] fix: update schema validation logic and improve error handling --- .../schema/DtrTestSchemaGenerator.java | 19 +++++++++++-------- .../schema/TypeApiSchemaGenerator.java | 3 ++- .../schema/SchemaSetGeneratorTest.java | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java index 2b88eeee..2167eae7 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/DtrTestSchemaGenerator.java @@ -29,7 +29,7 @@ public class DtrTestSchemaGenerator implements SchemaGenerator { protected static final String ORIGIN = "dtr-test"; protected final URI baseUrl; protected final RestClient http; - JsonSchemaFactory schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); + JsonSchemaFactory schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V4); public DtrTestSchemaGenerator(@NotNull ApplicationProperties props) { try { @@ -64,14 +64,17 @@ public SchemaInfo generateSchema(@NotNull String maybeTypePid) { if (status.is2xxSuccessful()) { JsonSchema schema = null; try (InputStream inputStream = response.getBody()) { - JsonNode schemaDocument = Application.jsonObjectMapper() - .readTree(inputStream) - .path("validationSchema"); - schema = this.schemaFactory.getSchema(schemaDocument); - if (schema == null || schema.getSchemaNode().isNull()) { - throw new IOException("Could not create valid schema for %s from %s " - .formatted(maybeTypePid, schemaDocument)); + JsonNode schemaNode = Application.jsonObjectMapper().readTree( + Application.jsonObjectMapper() + .readTree(inputStream) + .path("validationSchema") + .asText()); + schema = this.schemaFactory.getSchema(schemaNode); + if (schema == null || schema.getSchemaNode().isMissingNode() || schema.getSchemaNode().isTextual()) { + throw new IOException(ORIGIN + "could not create valid schema for %s from %s " + .formatted(maybeTypePid, schemaNode)); } + schema.initializeValidators(); } catch (IOException e) { return new SchemaInfo( ORIGIN, diff --git a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java index daf69d05..76252c95 100644 --- a/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java +++ b/src/main/java/edu/kit/datamanager/pit/typeregistry/schema/TypeApiSchemaGenerator.java @@ -66,10 +66,11 @@ public SchemaInfo generateSchema(@NotNull String maybeTypePid) { JsonNode schemaDocument = Application.jsonObjectMapper() .readTree(inputStream); schema = schemaFactory.getSchema(schemaDocument); - if (schema == null || schema.getSchemaNode().isNull()) { + if (schema == null || schema.getSchemaNode().isMissingNode() || schema.getSchemaNode().isTextual()) { throw new IOException("Could not create valid schema for %s from %s " .formatted(maybeTypePid, schemaDocument)); } + schema.initializeValidators(); } catch (IOException e) { return new SchemaInfo( this.baseUrl.toString(), diff --git a/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java index 7322f36d..2ed50b74 100644 --- a/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java +++ b/src/test/java/edu/kit/datamanager/pit/typeregistry/schema/SchemaSetGeneratorTest.java @@ -34,7 +34,7 @@ private static Stream typeWithExamplesAndCounterexamples() { Arguments.of( "21.T11148/92e200311a56800b3e47", "{ \"sha256sum\": \"sha256 c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\" }", - "\"c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\""), + "\"blabla c50624fd5ddd2b9652b72e2d2eabcb31a54b777718ab6fb7e44b582c20239a7c\""), // checksum Arguments.of( "21.T11148/92e200311a56800b3e47", From 7d574c3184794399c4863c95c7bd4db0062286a5 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Thu, 12 Jun 2025 17:44:35 +0200 Subject: [PATCH 101/108] [Gradle Release Plugin] - pre tag commit: 'v2.2.1'. --- CITATION.cff | 3 ++- gradle.properties | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 6497d9fd..c52b8021 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -14,7 +14,8 @@ authors: title: "Typed PID Maker" type: software abstract: The Typed PID Maker is the entry point to integrate digital resources into the FAIR DO ecosystem. It allows to create PIDs for resources and to provide them with the necessary metadata to ensure that the resources can be found. -date-released: 2020-10-01 +date-released: 2025-06-12 url: "https://github.com/kit-data-manager/pit-service" repository-code: "https://github.com/kit-data-manager/pit-service" license: Apache-2.0 +version: 2.2.1 diff --git a/gradle.properties b/gradle.properties index 3e3f9f23..ab948967 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,4 +14,4 @@ systemProp.jdk.tls.client.protocols="TLSv1,TLSv1.1,TLSv1.2" -version=2.2.1-rc3 \ No newline at end of file +version=2.2.1 \ No newline at end of file From 201395eb2442f27d523b72c69cf3970348d36331 Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Thu, 12 Jun 2025 17:44:45 +0200 Subject: [PATCH 102/108] [Gradle Release Plugin] - new version commit: 'v2.2.2'. --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index ab948967..83fe6516 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,4 +14,4 @@ systemProp.jdk.tls.client.protocols="TLSv1,TLSv1.1,TLSv1.2" -version=2.2.1 \ No newline at end of file +version=2.2.2 \ No newline at end of file From 74b9428387bc596efafd168c6868c5a87cbc6735 Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Tue, 15 Jul 2025 17:09:57 +0200 Subject: [PATCH 103/108] added mapping from user-provided PIDs to real Handle PIDs to result of Batch Record creation endpoint + tests Signed-off-by: Maximilian Inckmann --- .../pit/web/BatchRecordResponse.java | 36 +++ .../pit/web/ITypingRestResource.java | 52 ++-- .../pit/web/impl/TypingRESTResourceImpl.java | 36 +-- .../pit/web/ConnectedPIDsTest.java | 276 ++++++++++-------- 4 files changed, 233 insertions(+), 167 deletions(-) create mode 100644 src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java diff --git a/src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java b/src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java new file mode 100644 index 00000000..f8a3fa11 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package edu.kit.datamanager.pit.web; + +import edu.kit.datamanager.pit.domain.PIDRecord; + +import java.util.List; +import java.util.Map; + +/** + * Response object for batch record operations. + * Contains a list of the processed PID records and a mapping of the user-provided "fictionary" identifiers to their corresponding real record Handle PIDs. + * This mapping was used to link the user-provided identifiers with the actual records in the system. + *

+ * Arguments: + * - pidRecords: List of PIDRecord objects representing the processed records. (List) + * - mapping: Map where keys are user-provided identifiers (fictionary) and values are the corresponding real record Handle PIDs. (Map) + * + * @see PIDRecord + */ +public record BatchRecordResponse(List pidRecords, Map mapping) { +} diff --git a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java index bce03371..72fe23af 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java +++ b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java @@ -59,7 +59,7 @@ public interface ITypingRestResource { * @param dryrun If true, only validation will be done and no PIDs will be created. No data will be changed and no services will be notified. * @return either 201 and a list of record representations, or an error (see ApiResponse annotations and tests). * @throws IOException if an error occurs. - * @throws edu.kit.datamanager.pit.common.RecordValidationException if any of the records is invalid or a PID was used for multiple records in the same request. + * @throws edu.kit.datamanager.pit.common.RecordValidationException if any of the records is invalid, or a PID was used for multiple records in the same request. */ @PostMapping( path = "pids", @@ -80,7 +80,7 @@ public interface ITypingRestResource { @ApiResponses(value = { @ApiResponse( responseCode = "201", - description = "Successfully created all records and resolved references (if they exist). The response contains the created records.", + description = "Successfully created all records and resolved references (if they exist). The response contains the created records and the mapping used to map from the user-provided, fictionary PIDs to the actual Handle PIDs created in the process.", content = { @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, array = @ArraySchema(schema = @Schema(implementation = PIDRecord.class))) }), @@ -91,10 +91,10 @@ public interface ITypingRestResource { @ApiResponse(responseCode = "503", description = "Communication to required external service failed.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), @ApiResponse(responseCode = "500", description = "Server error. See body for details.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) }) - ResponseEntity> createPIDs( + ResponseEntity createPIDs( @RequestBody final List rec, - @Parameter(description = "If true, only validation will be done and no PIDs will be created. No data will be changed and no services will be notified.", required = false) + @Parameter(description = "If true, only validation will be done and no PIDs will be created. No data will be changed and no services will be notified.") @RequestParam(name = "dryrun", required = false, defaultValue = "false") boolean dryrun, @@ -119,7 +119,7 @@ ResponseEntity> createPIDs( * @param rec The PID record. * @return either 201 and a record representation, or an error (see ApiResponse * annotations and tests). - * @throws IOException + * @throws IOException if an error occurs. */ @PostMapping( path = "pid/", @@ -134,7 +134,7 @@ ResponseEntity> createPIDs( " the profile." + " Validation takes some time, depending on the context. It depends a lot on the size" + " of your record and the already cached information. This information is gathered" + - " from external services. If there are connection issues or hickups at these sites," + + " from external services. If there are connection issues or hiccups at these sites," + " validation may even take up to a few seconds. Usually you can expect the request" + " to be between 100ms up to 1000ms on a fast machine with reliable connections." ) @@ -167,8 +167,7 @@ ResponseEntity createPID( @Parameter( description = "If true, only validation will be done" + " and no PID will be created. No data will be changed" + - " and no services will be notified.", - required = false + " and no services will be notified." ) @RequestParam(name = "dryrun", required = false, defaultValue = "false") boolean dryrun, @@ -188,7 +187,7 @@ ResponseEntity createPID( * * @param rec the PID record. * @return the record (on success). - * @throws IOException + * @throws IOException if an error occurs. */ @PutMapping( path = "pid/**", @@ -235,8 +234,7 @@ ResponseEntity updatePID( " validation checks are performed, and the expected" + " response, including the new eTag, will be returned." + " No data will be changed and no services will be" + - " notified.", - required = false + " notified." ) @RequestParam(name = "dryrun", required = false, defaultValue = "false") boolean dryrun, @@ -273,8 +271,7 @@ ResponseEntity getRecord( @Parameter( description = "If true, validation will be run on the" + " resolved PID. On failure, an error will be" + - " returned. On success, the PID will be resolved.", - required = false + " returned. On success, the PID will be resolved." ) @RequestParam(name = "validation", required = false, defaultValue = "false") boolean validation, @@ -289,12 +286,13 @@ ResponseEntity getRecord( * returned together with the timestamps of creation and modification executed * on this PID by this service. *

- * This store is not a cache! Instead, the service remembers every PID which it + * This store is not a cache! + * Instead, the service remembers every PID that it * created (and resolved, depending on the configuration parameter * `pit.storage.strategy` of the service) on request. * * @return the known PID and its timestamps. - * @throws IOException + * @throws IOException if an error occurs. */ @Operation( summary = "Returns a PID and its timestamps from the local store, if available.", @@ -329,7 +327,7 @@ ResponseEntity findByPid( * Several filtering criteria are also available. *

* Known PIDs are defined as being stored in a local store. This store is not a - * cache! Instead, the service remembers every PID which it created (and + * cache! Instead, the service remembers every PID that it created (and * resolved, depending on the configuration parameter `pit.storage.strategy` of * the service) on request. * @@ -340,7 +338,7 @@ ResponseEntity findByPid( * @param modifiedBefore defines the latest date for the modification timestamp. * @param pageable defines page size and page to navigate through large * lists. - * @return the PIDs matching all given contraints. + * @return the PIDs matching all given constraints. */ @Operation( summary = "Returns all known PIDs. Supports paging, filtering criteria, and different formats.", @@ -362,19 +360,19 @@ ResponseEntity findByPid( @GetMapping(path = "known-pid") @PageableAsQueryParam ResponseEntity> findAll( - @Parameter(name = "created_after", description = "The UTC time of the earliest creation timestamp of a returned PID.", required = false) + @Parameter(name = "created_after", description = "The UTC time of the earliest creation timestamp of a returned PID.") @RequestParam(name = "created_after", required = false) Instant createdAfter, - @Parameter(name = "created_before", description = "The UTC time of the latest creation timestamp of a returned PID.", required = false) + @Parameter(name = "created_before", description = "The UTC time of the latest creation timestamp of a returned PID.") @RequestParam(name = "created_before", required = false) Instant createdBefore, - @Parameter(name = "modified_after", description = "The UTC time of the earliest modification timestamp of a returned PID.", required = false) + @Parameter(name = "modified_after", description = "The UTC time of the earliest modification timestamp of a returned PID.") @RequestParam(name = "modified_after", required = false) Instant modifiedAfter, - @Parameter(name = "modified_before", description = "The UTC time of the latest modification timestamp of a returned PID.", required = false) + @Parameter(name = "modified_before", description = "The UTC time of the latest modification timestamp of a returned PID.") @RequestParam(name = "modified_before", required = false) Instant modifiedBefore, @@ -391,7 +389,7 @@ ResponseEntity> findAll( /** * Like findAll, but the return value is formatted for the tabulator - * javascript library. + * JavaScript library. * * @param createdAfter defines the earliest date for the creation timestamp. * @param createdBefore defines the latest date for the creation timestamp. @@ -400,7 +398,7 @@ ResponseEntity> findAll( * @param modifiedBefore defines the latest date for the modification timestamp. * @param pageable defines page size and page to navigate through large * lists. - * @return the PIDs matching all given contraints. + * @return the PIDs matching all given constraints. */ @Operation( summary = "Returns all known PIDs. Supports paging, filtering criteria, and different formats.", @@ -422,19 +420,19 @@ ResponseEntity> findAll( @GetMapping(path = "known-pid", produces = {"application/tabulator+json"}, headers = "Accept=application/tabulator+json") @PageableAsQueryParam ResponseEntity> findAllForTabular( - @Parameter(name = "created_after", description = "The UTC time of the earliest creation timestamp of a returned PID.", required = false) + @Parameter(name = "created_after", description = "The UTC time of the earliest creation timestamp of a returned PID.") @RequestParam(name = "created_after", required = false) Instant createdAfter, - @Parameter(name = "created_before", description = "The UTC time of the latest creation timestamp of a returned PID.", required = false) + @Parameter(name = "created_before", description = "The UTC time of the latest creation timestamp of a returned PID.") @RequestParam(name = "created_before", required = false) Instant createdBefore, - @Parameter(name = "modified_after", description = "The UTC time of the earliest modification timestamp of a returned PID.", required = false) + @Parameter(name = "modified_after", description = "The UTC time of the earliest modification timestamp of a returned PID.") @RequestParam(name = "modified_after", required = false) Instant modifiedAfter, - @Parameter(name = "modified_before", description = "The UTC time of the latest modification timestamp of a returned PID.", required = false) + @Parameter(name = "modified_before", description = "The UTC time of the latest modification timestamp of a returned PID.") @RequestParam(name = "modified_before", required = false) Instant modifiedBefore, diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index 9487ffeb..9a518294 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -30,6 +30,7 @@ import edu.kit.datamanager.pit.pidlog.KnownPidsDao; import edu.kit.datamanager.pit.pitservice.ITypingService; import edu.kit.datamanager.pit.resolver.Resolver; +import edu.kit.datamanager.pit.web.BatchRecordResponse; import edu.kit.datamanager.pit.web.ITypingRestResource; import edu.kit.datamanager.pit.web.TabulatorPaginationFormat; import edu.kit.datamanager.service.IMessagingService; @@ -92,7 +93,7 @@ public TypingRESTResourceImpl() { } @Override - public ResponseEntity> createPIDs( + public ResponseEntity createPIDs( List rec, boolean dryrun, WebRequest request, @@ -101,14 +102,15 @@ public ResponseEntity> createPIDs( ) throws IOException, RecordValidationException, ExternalServiceException { if (rec == null || rec.isEmpty()) { LOG.warn("No records provided for PID creation."); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Collections.emptyList()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new BatchRecordResponse(Collections.emptyList(), Collections.emptyMap())); } if (rec.size() == 1) { // If only one record is provided, we can use the single record creation method. LOG.info("Only one record provided. Using single record creation method."); var result = createPID(rec.getFirst(), dryrun, request, response, uriBuilder); // Return the single record in a list - return ResponseEntity.status(result.getStatusCode()).headers(result.getHeaders()).body(Collections.singletonList(result.getBody())); + assert result.getBody() != null; + return ResponseEntity.status(result.getStatusCode()).headers(result.getHeaders()).body(new BatchRecordResponse(Collections.singletonList(result.getBody()), Collections.singletonMap(rec.getFirst().getPid(), result.getBody().getPid()))); } Instant startTime = Instant.now(); LOG.info("Creating PIDs for {} records.", rec.size()); @@ -128,7 +130,7 @@ public ResponseEntity> createPIDs( LOG.info("-- Time taken for mapping: {} ms", ChronoUnit.MILLIS.between(startTime, mappingTime)); LOG.info("-- Time taken for validation: {} ms", ChronoUnit.MILLIS.between(mappingTime, validationTime)); LOG.info("Dryrun finished. Returning validated records for {} records.", validatedRecords.size()); - return ResponseEntity.status(HttpStatus.OK).body(validatedRecords); + return ResponseEntity.status(HttpStatus.OK).body(new BatchRecordResponse(validatedRecords, pidMappings)); } List failedRecords = new ArrayList<>(); @@ -184,10 +186,10 @@ public ResponseEntity> createPIDs( } LOG.info("Creation finished. Returning validated records for {} records. {} records failed to be created.", validatedRecords.size(), failedRecords.size()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(failedRecords); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new BatchRecordResponse(failedRecords, pidMappings)); } else { LOG.info("Creation finished. Returning validated records for {} records.", validatedRecords.size()); - return ResponseEntity.status(HttpStatus.CREATED).body(validatedRecords); + return ResponseEntity.status(HttpStatus.CREATED).body(new BatchRecordResponse(validatedRecords, pidMappings)); } } @@ -197,11 +199,10 @@ public ResponseEntity> createPIDs( * @param rec the list of records produced by the user * @param dryrun whether the operation is a dryrun or not * @return a map between the user-provided PIDs (key) and the real PIDs (values) - * @throws IOException if the prefix is not configured * @throws RecordValidationException if the same internal PID is used for multiple records * @throws ExternalServiceException if the PID generation fails */ - private Map generatePIDMapping(List rec, boolean dryrun) throws IOException, RecordValidationException, ExternalServiceException { + private Map generatePIDMapping(List rec, boolean dryrun) throws RecordValidationException, ExternalServiceException { Map pidMappings = new HashMap<>(); for (PIDRecord pidRecord : rec) { String internalPID = pidRecord.getPid(); // the internal PID is the one given by the user @@ -264,7 +265,7 @@ public ResponseEntity createPID( final WebRequest request, final HttpServletResponse response, final UriComponentsBuilder uriBuilder - ) throws IOException { + ) { LOG.info("Creating PID"); if (dryrun) { @@ -304,7 +305,7 @@ private boolean hasPid(PIDRecord pidRecord) { return pidRecord.getPid() != null && !pidRecord.getPid().isBlank(); } - private void setPid(PIDRecord pidRecord) throws IOException { + private void setPid(PIDRecord pidRecord) { boolean hasCustomPid = hasPid(pidRecord); boolean allowsCustomPids = pidGenerationProperties.isCustomClientPidsEnabled(); @@ -344,7 +345,7 @@ public ResponseEntity updatePID( final WebRequest request, final HttpServletResponse response, final UriComponentsBuilder uriBuilder - ) throws IOException { + ) { // PID validation String pid = getContentPathFromRequest("pid", request); String pidInternal = pidRecord.getPid(); @@ -427,7 +428,7 @@ public ResponseEntity getRecord( final WebRequest request, final HttpServletResponse response, final UriComponentsBuilder uriBuilder - ) throws IOException { + ) { String pid = getContentPathFromRequest("pid", request); PIDRecord pidRecord = this.resolver.resolve(pid); if (applicationProps.getStorageStrategy().storesResolved()) { @@ -456,10 +457,9 @@ public ResponseEntity findByPid( ) throws IOException { String pid = getContentPathFromRequest("known-pid", request); Optional known = this.localPidStorage.findByPid(pid); - if (known.isPresent()) { - return ResponseEntity.ok().body(known.get()); - } - return ResponseEntity.notFound().build(); + return known + .map(knownPid -> ResponseEntity.ok().body(knownPid)) + .orElseGet(() -> ResponseEntity.notFound().build()); } public Page findAllPage( @@ -515,7 +515,7 @@ public ResponseEntity> findAll( Pageable pageable, WebRequest request, HttpServletResponse response, - UriComponentsBuilder uriBuilder) throws IOException { + UriComponentsBuilder uriBuilder) { Page page = this.findAllPage(createdAfter, createdBefore, modifiedAfter, modifiedBefore, pageable); response.addHeader( HeaderConstants.CONTENT_RANGE, @@ -535,7 +535,7 @@ public ResponseEntity> findAllForTabular( Pageable pageable, WebRequest request, HttpServletResponse response, - UriComponentsBuilder uriBuilder) throws IOException { + UriComponentsBuilder uriBuilder) { Page page = this.findAllPage(createdAfter, createdBefore, modifiedAfter, modifiedBefore, pageable); response.addHeader( HeaderConstants.CONTENT_RANGE, diff --git a/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java b/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java index 3e6effa3..7a4de960 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java @@ -18,12 +18,8 @@ package edu.kit.datamanager.pit.web; import com.fasterxml.jackson.databind.ObjectMapper; -import edu.kit.datamanager.pit.configuration.ApplicationProperties; import edu.kit.datamanager.pit.domain.PIDRecord; -import edu.kit.datamanager.pit.pidgeneration.PidSuffixGenerator; import edu.kit.datamanager.pit.pidlog.KnownPidsDao; -import edu.kit.datamanager.pit.pitservice.ITypingService; -import edu.kit.datamanager.pit.typeregistry.ITypeRegistry; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -39,8 +35,6 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import org.springframework.web.context.WebApplicationContext; -import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @@ -54,9 +48,6 @@ @TestPropertySource("/test/application-test.properties") @ActiveProfiles("test") class ConnectedPIDsTest { - private static final Instant NOW = Instant.now().plus(1, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS); - private static final Instant YESTERDAY = NOW.minus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); - private static final Instant TOMORROW = NOW.plus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); private static final int RECORD_COUNT = 16; private static final int LARGE_RECORD_COUNT = 200; @@ -67,23 +58,15 @@ class ConnectedPIDsTest { "21.T11148/2a1cad55473b20407c78" }; - @Autowired - ITypingService typingService; - @Autowired - ITypeRegistry typeRegistry; @Autowired private WebApplicationContext webApplicationContext; - @Autowired - private PidSuffixGenerator pidGenerator; - @Autowired - private ApplicationProperties appProps; private MockMvc mockMvc; private ObjectMapper mapper; @Autowired private KnownPidsDao knownPidsDao; @BeforeEach - void setup() throws Exception { + void setup() { this.mockMvc = webAppContextSetup(webApplicationContext).build(); this.mapper = new ObjectMapper(); knownPidsDao.deleteAll(); @@ -91,10 +74,10 @@ void setup() throws Exception { @Test void checkTestSetup() { - assertNotNull(mockMvc); - assertNotNull(mapper); - assertNotNull(knownPidsDao); - assertEquals(0, knownPidsDao.count()); + assertNotNull(mockMvc, "MockMvc should be initialized"); + assertNotNull(mapper, "Object mapper should be initialized"); + assertNotNull(knownPidsDao, "KnownPidsDao should be initialized"); + assertEquals(0, knownPidsDao.count(), "Database should be empty at test start"); } @Test @@ -104,78 +87,78 @@ void testPIDBuilderAllMethods() { // Test default constructor PIDBuilder defaultBuilder = new PIDBuilder(); - assertNotNull(defaultBuilder); - assertNotNull(defaultBuilder.build()); + assertNotNull(defaultBuilder, "Default PIDBuilder constructor should create non-null instance"); + assertNotNull(defaultBuilder.build(), "Default PIDBuilder should build non-null PID"); // Test constructor with seed PIDBuilder seededBuilder = new PIDBuilder(testSeed); - assertNotNull(seededBuilder); - assertEquals(testSeed, seededBuilder.seed); + assertNotNull(seededBuilder, "Seeded PIDBuilder constructor should create non-null instance"); + assertEquals(testSeed, seededBuilder.seed, "Seeded PIDBuilder should have the correct seed value"); // Test withSeed method PIDBuilder seedModified = new PIDBuilder().withSeed(testSeed); - assertEquals(testSeed, seedModified.seed); + assertEquals(testSeed, seedModified.seed, "withSeed method should set the correct seed value"); // Test all prefix methods PIDBuilder validPrefixBuilder = new PIDBuilder(testSeed).validPrefix(); String validPid = validPrefixBuilder.build(); - assertTrue(validPid.startsWith("sandboxed/")); + assertTrue(validPid.startsWith("sandboxed/"), "validPrefix should create PID starting with 'sandboxed/'"); PIDBuilder unauthorizedPrefixBuilder = new PIDBuilder(testSeed).unauthorizedPrefix(); String unauthorizedPid = unauthorizedPrefixBuilder.build(); - assertTrue(unauthorizedPid.startsWith("0.NA/")); + assertTrue(unauthorizedPid.startsWith("0.NA/"), "unauthorizedPrefix should create PID starting with '0.NA/'"); PIDBuilder emptyPrefixBuilder = new PIDBuilder(testSeed).emptyPrefix(); String emptyPrefixPid = emptyPrefixBuilder.build(); - assertTrue(emptyPrefixPid.startsWith("/")); + assertTrue(emptyPrefixPid.startsWith("/"), "emptyPrefix should create PID starting with '/'"); PIDBuilder invalidPrefixBuilder = new PIDBuilder(testSeed).invalidCharactersPrefix(); String invalidPrefixPid = invalidPrefixBuilder.build(); - assertNotNull(invalidPrefixPid); + assertNotNull(invalidPrefixPid, "invalidCharactersPrefix should create non-null PID"); // Test withPrefix method PIDBuilder customPrefixBuilder = new PIDBuilder(testSeed).withPrefix("custom.prefix"); String customPrefixPid = customPrefixBuilder.build(); - assertTrue(customPrefixPid.startsWith("custom.prefix/")); + assertTrue(customPrefixPid.startsWith("custom.prefix/"), "withPrefix should create PID starting with the specified prefix"); // Test all suffix methods PIDBuilder validSuffixBuilder = new PIDBuilder(testSeed).validPrefix().validSuffix(); String validSuffixPid = validSuffixBuilder.build(); - assertNotNull(validSuffixPid); + assertNotNull(validSuffixPid, "validSuffix should create non-null PID"); PIDBuilder emptySuffixBuilder = new PIDBuilder(testSeed).validPrefix().emptySuffix(); String emptySuffixPid = emptySuffixBuilder.build(); - assertTrue(emptySuffixPid.endsWith("/")); + assertTrue(emptySuffixPid.endsWith("/"), "emptySuffix should create PID ending with '/'"); PIDBuilder invalidSuffixBuilder = new PIDBuilder(testSeed).validPrefix().invalidCharactersSuffix(); String invalidSuffixPid = invalidSuffixBuilder.build(); - assertNotNull(invalidSuffixPid); + assertNotNull(invalidSuffixPid, "invalidCharactersSuffix should create non-null PID"); // Test withSuffix method PIDBuilder customSuffixBuilder = new PIDBuilder(testSeed).validPrefix().withSuffix("custom-suffix"); String customSuffixPid = customSuffixBuilder.build(); - assertTrue(customSuffixPid.endsWith("custom-suffix")); + assertTrue(customSuffixPid.endsWith("custom-suffix"), "withSuffix should create PID ending with the specified suffix"); // Test clone method PIDBuilder originalBuilder = new PIDBuilder(testSeed).validPrefix().validSuffix(); PIDBuilder clonedBuilder = originalBuilder.clone(); - assertEquals(originalBuilder.build(), clonedBuilder.build()); - assertNotSame(originalBuilder, clonedBuilder); + assertEquals(originalBuilder.build(), clonedBuilder.build(), "Cloned builder should produce the same PID"); + assertNotSame(originalBuilder, clonedBuilder, "Cloned builder should be a different instance"); // Test clone(PIDBuilder) method PIDBuilder targetBuilder = new PIDBuilder(); targetBuilder.clone(originalBuilder); - assertEquals(originalBuilder.build(), targetBuilder.build()); + assertEquals(originalBuilder.build(), targetBuilder.build(), "Target builder should produce the same PID after cloning"); // Test equals and hashCode PIDBuilder builder1 = new PIDBuilder(testSeed).validPrefix().validSuffix(); PIDBuilder builder2 = new PIDBuilder(testSeed).validPrefix().validSuffix(); - assertEquals(builder1, builder2); - assertEquals(builder1.hashCode(), builder2.hashCode()); + assertEquals(builder1, builder2, "Builders with same configuration should be equal"); + assertEquals(builder1.hashCode(), builder2.hashCode(), "Equal builders should have same hash code"); // Test toString - assertNotNull(originalBuilder.toString()); - assertTrue(originalBuilder.toString().contains("PIDBuilder")); + assertNotNull(originalBuilder.toString(), "toString should not return null"); + assertTrue(originalBuilder.toString().contains("PIDBuilder"), "toString should contain class name"); } @Test @@ -185,97 +168,97 @@ void testPIDRecordBuilderAllMethods() { // Test default constructor PIDRecordBuilder defaultBuilder = new PIDRecordBuilder(); - assertNotNull(defaultBuilder); - assertNotNull(defaultBuilder.build()); + assertNotNull(defaultBuilder, "Default PIDRecordBuilder constructor should create non-null instance"); + assertNotNull(defaultBuilder.build(), "Default PIDRecordBuilder should build non-null record"); // Test constructor with PIDBuilder PIDBuilder pidBuilder = new PIDBuilder(testSeed).validPrefix().validSuffix(); PIDRecordBuilder builderWithPid = new PIDRecordBuilder(pidBuilder); - assertNotNull(builderWithPid); - assertEquals(pidBuilder.build(), builderWithPid.build().getPid()); + assertNotNull(builderWithPid, "PIDRecordBuilder with PIDBuilder should create non-null instance"); + assertEquals(pidBuilder.build(), builderWithPid.build().getPid(), "PIDRecordBuilder should use PID from provided PIDBuilder"); // Test constructor with PIDBuilder and seed PIDRecordBuilder builderWithSeed = new PIDRecordBuilder(pidBuilder, testSeed); - assertNotNull(builderWithSeed); - assertEquals(testSeed, builderWithSeed.seed); + assertNotNull(builderWithSeed, "PIDRecordBuilder with PIDBuilder and seed should create non-null instance"); + assertEquals(testSeed, builderWithSeed.seed, "PIDRecordBuilder should store the provided seed"); // Test withSeed method PIDRecordBuilder seedModified = new PIDRecordBuilder().withSeed(testSeed); - assertEquals(testSeed, seedModified.seed); + assertEquals(testSeed, seedModified.seed, "withSeed method should set the correct seed value"); // Test withPid method String customPid = "test/custom-pid"; PIDRecordBuilder pidModified = new PIDRecordBuilder().withPid(customPid); - assertEquals(customPid, pidModified.build().getPid()); + assertEquals(customPid, pidModified.build().getPid(), "withPid method should set the custom PID value"); - // Test completeProfile method + // Test the completeProfile method PIDRecordBuilder profileBuilder = new PIDRecordBuilder().completeProfile(); PIDRecord profileRecord = profileBuilder.build(); - assertNotNull(profileRecord); - assertTrue(profileRecord.getEntries().size() > 0); + assertNotNull(profileRecord, "completeProfile should produce a non-null record"); + assertFalse(profileRecord.getEntries().isEmpty(), "completeProfile should generate record entries"); // Test incompleteProfile method PIDRecordBuilder incompleteBuilder = new PIDRecordBuilder().incompleteProfile(); PIDRecord incompleteRecord = incompleteBuilder.build(); - assertNotNull(incompleteRecord); + assertNotNull(incompleteRecord, "incompleteProfile should produce a non-null record"); // Test invalidValues method with different parameters PIDRecordBuilder invalidValuesBuilder1 = new PIDRecordBuilder().invalidValues(3); PIDRecord invalidRecord1 = invalidValuesBuilder1.build(); - assertNotNull(invalidRecord1); + assertNotNull(invalidRecord1, "invalidValues(3) should produce a non-null record"); PIDRecordBuilder invalidValuesBuilder2 = new PIDRecordBuilder().invalidValues(2, "21.T11148/397d831aa3a9d18eb52c"); PIDRecord invalidRecord2 = invalidValuesBuilder2.build(); - assertNotNull(invalidRecord2); + assertNotNull(invalidRecord2, "invalidValues with specific key should produce a non-null record"); PIDRecordBuilder invalidValuesBuilder3 = new PIDRecordBuilder().invalidValues(0); PIDRecord invalidRecord3 = invalidValuesBuilder3.build(); - assertNotNull(invalidRecord3); + assertNotNull(invalidRecord3, "invalidValues(0) should produce a non-null record"); // Test invalidKeys method PIDRecordBuilder invalidKeysBuilder = new PIDRecordBuilder().invalidKeys(3); PIDRecord invalidKeysRecord = invalidKeysBuilder.build(); - assertNotNull(invalidKeysRecord); - assertTrue(invalidKeysRecord.getEntries().size() >= 3); + assertNotNull(invalidKeysRecord, "invalidKeys should produce a non-null record"); + assertTrue(invalidKeysRecord.getEntries().size() >= 3, "invalidKeys(3) should generate at least 3 entries"); // Test emptyRecord method PIDRecordBuilder emptyBuilder = new PIDRecordBuilder().completeProfile().emptyRecord(); PIDRecord emptyRecord = emptyBuilder.build(); - assertNotNull(emptyRecord); - assertEquals(0, emptyRecord.getEntries().size()); + assertNotNull(emptyRecord, "emptyRecord should produce a non-null record"); + assertEquals(0, emptyRecord.getEntries().size(), "emptyRecord should have no entries"); // Test nullRecord method PIDRecordBuilder nullBuilder = new PIDRecordBuilder().nullRecord(); - assertThrows(Exception.class, () -> nullBuilder.build()); + assertThrows(Exception.class, nullBuilder::build, "nullRecord should throw exception when built"); // Test withPIDRecord method PIDRecord existingRecord = new PIDRecord().withPID("test/existing"); existingRecord.addEntry("test.key", "test.value"); PIDRecordBuilder recordBuilder = new PIDRecordBuilder().withPIDRecord(existingRecord); - assertEquals(existingRecord.getPid(), recordBuilder.build().getPid()); + assertEquals(existingRecord.getPid(), recordBuilder.build().getPid(), "withPIDRecord should use PID from existing record"); // Test clone method PIDRecordBuilder originalRecordBuilder = new PIDRecordBuilder().completeProfile(); PIDRecordBuilder clonedRecordBuilder = originalRecordBuilder.clone(); - assertNotSame(originalRecordBuilder, clonedRecordBuilder); - assertEquals(originalRecordBuilder.seed, clonedRecordBuilder.seed); + assertNotSame(originalRecordBuilder, clonedRecordBuilder, "Cloned builder should be a different instance"); + assertEquals(originalRecordBuilder.seed, clonedRecordBuilder.seed, "Cloned builder should have the same seed"); // Test equals and hashCode PIDRecordBuilder builder1 = new PIDRecordBuilder(null, testSeed).completeProfile(); PIDRecordBuilder builder2 = new PIDRecordBuilder(null, testSeed).completeProfile(); // Note: equals might not be equal due to random elements, but we test the method exists - assertNotNull(builder1.equals(builder2)); - assertNotNull(builder1.hashCode()); + assertEquals(builder1, builder2, "Builders with same configuration should be equal"); + assertNotEquals(0, builder1.hashCode(), "hashCode should not return null"); // Test toString - assertNotNull(originalRecordBuilder.toString()); - assertTrue(originalRecordBuilder.toString().contains("PIDRecordBuilder")); + assertNotNull(originalRecordBuilder.toString(), "toString should not return null"); + assertTrue(originalRecordBuilder.toString().contains("PIDRecordBuilder"), "toString should contain class name"); } @Test @DisplayName("Test PIDRecordBuilder connection functionality") void testPIDRecordBuilderConnections() { - Long testSeed = 111L; + long testSeed = 111L; // Create multiple builders for connection testing PIDRecordBuilder builder1 = new PIDRecordBuilder(null, testSeed).completeProfile(); @@ -287,28 +270,28 @@ void testPIDRecordBuilderConnections() { builder1.addConnection(connectionKey, false, builder2, builder3); PIDRecord connectedRecord = builder1.build(); - assertTrue(connectedRecord.hasProperty(connectionKey)); + assertTrue(connectedRecord.hasProperty(connectionKey), "Record should have the connection property after addConnection"); - // Test addConnection with replace + // Test addConnection with replacement builder1.addConnection(connectionKey, true, builder2); PIDRecord replacedRecord = builder1.build(); - assertTrue(replacedRecord.hasProperty(connectionKey)); + assertTrue(replacedRecord.hasProperty(connectionKey), "Record should have connection property after replace"); // Test addConnection error case assertThrows(IllegalArgumentException.class, () -> - builder1.addConnection(connectionKey, false)); + builder1.addConnection(connectionKey, false), "addConnection should throw exception when no builders provided"); // Test connectRecordBuilders static method with default keys List connectedBuilders = PIDRecordBuilder.connectRecordBuilders( null, null, false, builder1, builder2, builder3); - assertEquals(3, connectedBuilders.size()); + assertEquals(3, connectedBuilders.size(), "connectRecordBuilders should return the same number of builders"); // Verify connections were established for (PIDRecordBuilder builder : connectedBuilders) { PIDRecord record = builder.build(); assertTrue(record.hasProperty("21.T11148/d0773859091aeb451528") || - record.hasProperty("21.T11148/4fe7cde52629b61e3b82")); + record.hasProperty("21.T11148/4fe7cde52629b61e3b82"), "Connected records should have forward or backward connection property"); } // Test connectRecordBuilders with custom keys @@ -318,18 +301,18 @@ void testPIDRecordBuilderConnections() { List customConnectedBuilders = PIDRecordBuilder.connectRecordBuilders( "custom.forward.key", "custom.backward.key", true, builder4, builder5); - assertEquals(2, customConnectedBuilders.size()); + assertEquals(2, customConnectedBuilders.size(), "connectRecordBuilders with custom keys should return the correct number of builders"); // Test connectRecordBuilders error case assertThrows(IllegalArgumentException.class, () -> - PIDRecordBuilder.connectRecordBuilders(null, null, false, builder1)); + PIDRecordBuilder.connectRecordBuilders(null, null, false, builder1), "connectRecordBuilders should throw exception with single builder"); } @Test @DisplayName("Test valid connected records creation") void checkValidConnectedRecords() throws Exception { // Create connected records using all builder functionality - Long baseSeed = 12345L; + long baseSeed = 12345L; List records = new ArrayList<>(); @@ -363,11 +346,17 @@ void checkValidConnectedRecords() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonContent)) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isCreated()); - - assertEquals(records.size(), knownPidsDao.count()); + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(records.size())) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").isMap()); + + assertEquals(records.size(), knownPidsDao.count(), "Number of stored records should match the number of records submitted"); } + @Test @DisplayName("Test single valid record creation") void testCreateSingleValidRecord() throws Exception { @@ -390,11 +379,16 @@ void testCreateSingleValidRecord() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonContent)) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isCreated()); + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(1)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); - assertEquals(1, knownPidsDao.count()); + assertEquals(1, knownPidsDao.count(), "Exactly one record should be stored in the database"); } + @Test @DisplayName("Test empty list") void testCreateEmptyList() throws Exception { @@ -408,7 +402,7 @@ void testCreateEmptyList() throws Exception { .andDo(MockMvcResultHandlers.print()) .andExpect(MockMvcResultMatchers.status().isBadRequest()); - assertEquals(0, knownPidsDao.count()); + assertEquals(0, knownPidsDao.count(), "No records should be stored in database when submitting an empty list"); } @Test @@ -427,9 +421,12 @@ void testDryRun() throws Exception { .content(jsonContent) .param("dryrun", "true")) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isOk()); + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); - assertEquals(0, knownPidsDao.count()); + assertEquals(0, knownPidsDao.count(), "No records should be stored in database when using dryrun mode"); } @Test @@ -479,9 +476,13 @@ void testCircularReferences() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonContent)) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isCreated()); + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(2)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); - assertEquals(2, knownPidsDao.count()); + assertEquals(2, knownPidsDao.count(), "Both circularly connected records should be stored in the database"); } @Test @@ -508,7 +509,7 @@ void testDuplicateTemporaryPids() throws Exception { @Test @DisplayName("Test records with missing entries") void testRecordsWithMissingEntries() throws Exception { - // Create incomplete record + // Create an incomplete record PIDRecord incompleteRecord = new PIDRecordBuilder() .incompleteProfile() .build(); @@ -550,9 +551,13 @@ void testLargeNumberOfConnectedRecords() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonContent)) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isCreated()); + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(LARGE_RECORD_COUNT)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); - assertEquals(LARGE_RECORD_COUNT, knownPidsDao.count()); + assertEquals(LARGE_RECORD_COUNT, knownPidsDao.count(), "All records from large batch should be stored in the database"); } @Test @@ -573,11 +578,16 @@ void testRecordsWithExternalReferences() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonContent)) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isCreated()); + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(1)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); - assertEquals(1, knownPidsDao.count()); + assertEquals(1, knownPidsDao.count(), "Record with external reference should be stored in the database"); } + @Test @DisplayName("Test records with mixed connection types") void testMixedConnectionTypes() throws Exception { @@ -598,9 +608,13 @@ void testMixedConnectionTypes() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonContent)) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isCreated()); + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(3)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); - assertEquals(3, knownPidsDao.count()); + assertEquals(3, knownPidsDao.count(), "All three records with mixed connection types should be stored in the database"); } @Test @@ -619,7 +633,11 @@ void testRecordsWithNullPids() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonContent)) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isCreated()); + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(1)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); } @Test @@ -639,12 +657,12 @@ void testPidMappingPersistence() throws Exception { .andDo(MockMvcResultHandlers.print()) .andExpect(MockMvcResultMatchers.status().isCreated()); - assertEquals(1, knownPidsDao.count()); + assertEquals(1, knownPidsDao.count(), "One record should be stored in the database"); // Verify the PID was actually stored - String storedPid = knownPidsDao.findAll().get(0).getPid(); - assertNotNull(storedPid); - assertFalse(storedPid.isEmpty()); + String storedPid = knownPidsDao.findAll().getFirst().getPid(); + assertNotNull(storedPid, "Stored PID should not be null"); + assertFalse(storedPid.isEmpty(), "Stored PID should not be empty"); } @Test @@ -658,29 +676,29 @@ void testPIDBuilderEdgeCases() { .unauthorizedPrefix() .validSuffix(); String unauthorizedValidPid = unauthorizedValid.build(); - assertTrue(unauthorizedValidPid.startsWith("0.NA/")); + assertTrue(unauthorizedValidPid.startsWith("0.NA/"), "Unauthorized prefix with valid suffix should start with '0.NA/'"); // Test empty prefix with empty suffix PIDBuilder emptyEmpty = new PIDBuilder(seed) .emptyPrefix() .emptySuffix(); String emptyEmptyPid = emptyEmpty.build(); - assertEquals("/", emptyEmptyPid); + assertEquals("/", emptyEmptyPid, "Empty prefix with empty suffix should result in just '/'"); // Test invalid characters combinations PIDBuilder invalidCombination = new PIDBuilder(seed) .invalidCharactersPrefix() .invalidCharactersSuffix(); String invalidPid = invalidCombination.build(); - assertNotNull(invalidPid); - assertTrue(invalidPid.contains("/")); + assertNotNull(invalidPid, "Invalid characters combination should still produce non-null PID"); + assertTrue(invalidPid.contains("/"), "Invalid characters combination should still contain the separator"); // Test custom prefix with custom suffix PIDBuilder customBoth = new PIDBuilder(seed) .withPrefix("test.prefix") .withSuffix("test-suffix"); String customPid = customBoth.build(); - assertEquals("test.prefix/test-suffix", customPid); + assertEquals("test.prefix/test-suffix", customPid, "Custom prefix and suffix should be combined correctly"); } @Test @@ -691,7 +709,7 @@ void testPIDRecordBuilderInvalidConfigurations() throws Exception { .invalidKeys(5) .build(); - assertTrue(invalidKeysRecord.getEntries().size() >= 5); + assertTrue(invalidKeysRecord.getEntries().size() >= 5, "invalidKeys(5) should generate at least 5 entries"); // Test record with invalid values for specific keys PIDRecord invalidSpecificRecord = new PIDRecordBuilder() @@ -699,14 +717,14 @@ void testPIDRecordBuilderInvalidConfigurations() throws Exception { .invalidValues(2, "21.T11148/397d831aa3a9d18eb52c", "21.T11148/8074aed799118ac263ad") .build(); - assertNotNull(invalidSpecificRecord); + assertNotNull(invalidSpecificRecord, "Invalid values for specific keys should produce non-null record"); // Test empty record PIDRecord emptyRecord = new PIDRecordBuilder() .emptyRecord() .build(); - assertEquals(0, emptyRecord.getEntries().size()); + assertEquals(0, emptyRecord.getEntries().size(), "emptyRecord should have no entries"); // Submit invalid records to test API response List invalidRecords = List.of(invalidKeysRecord); @@ -734,7 +752,7 @@ void testBuilderChainingCombinations() { .withSuffix("chained-suffix"); String complexPid = complexBuilder.build(); - assertEquals("chained.prefix/chained-suffix", complexPid); + assertEquals("chained.prefix/chained-suffix", complexPid, "Chained builder methods should override previous settings"); // Test complex PIDRecordBuilder chaining PIDRecordBuilder complexRecordBuilder = new PIDRecordBuilder() @@ -745,14 +763,14 @@ void testBuilderChainingCombinations() { .invalidKeys(1); PIDRecord complexRecord = complexRecordBuilder.build(); - assertEquals("custom/pid", complexRecord.getPid()); - assertTrue(complexRecord.getEntries().size() > 0); + assertEquals("custom/pid", complexRecord.getPid(), "Chained record builder should use the custom PID"); + assertFalse(complexRecord.getEntries().isEmpty(), "Chained record builder should generate entries"); // Test cloning and modification PIDRecordBuilder cloned = complexRecordBuilder.clone(); cloned.withPid("different/pid"); - assertNotEquals(complexRecordBuilder.build().getPid(), cloned.build().getPid()); + assertNotEquals(complexRecordBuilder.build().getPid(), cloned.build().getPid(), "Modifying a cloned builder should not affect the original"); } @Test @@ -776,9 +794,13 @@ void testMultipleRecordCreationWithRecordCount() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonContent)) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isCreated()); + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(RECORD_COUNT)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); - assertEquals(RECORD_COUNT, knownPidsDao.count()); + assertEquals(RECORD_COUNT, knownPidsDao.count(), "All RECORD_COUNT records should be stored in the database"); } @Test @@ -808,9 +830,13 @@ void testConnectedRecordsWithRecordCount() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(jsonContent)) .andDo(MockMvcResultHandlers.print()) - .andExpect(MockMvcResultMatchers.status().isCreated()); + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").exists()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) + .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(RECORD_COUNT)) + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()); - assertEquals(RECORD_COUNT, knownPidsDao.count()); + assertEquals(RECORD_COUNT, knownPidsDao.count(), "All RECORD_COUNT connected records should be stored in the database"); } @Test @@ -848,7 +874,7 @@ void testPartialFailureAndRollback() throws Exception { String jsonContent = mapper.writeValueAsString(records); - // This should result in a server error due to failed validation/creation + // This should result in a server error due to failed validation/ creation, // and the rollback mechanism should be triggered this.mockMvc .perform(post("/api/v1/pit/pids") @@ -858,7 +884,7 @@ void testPartialFailureAndRollback() throws Exception { .andExpect(MockMvcResultMatchers.status().isBadRequest()); // Verify that no PIDs were persisted due to rollback - assertEquals(0, knownPidsDao.count()); + assertEquals(0, knownPidsDao.count(), "Rollback should prevent any records from being stored due to partial failures"); } @Test @@ -897,6 +923,12 @@ void testBatchWithMixedRecordsForRollbackCoverage() throws Exception { .build(); records.add(invalidRecord); + // Incomplete record with missing required entries + PIDRecord incompleteRecord = new PIDRecordBuilder() + .incompleteProfile() + .build(); + records.add(incompleteRecord); + String jsonContent = mapper.writeValueAsString(records); // Expect server error due to validation failures @@ -908,7 +940,7 @@ void testBatchWithMixedRecordsForRollbackCoverage() throws Exception { .andExpect(MockMvcResultMatchers.status().isBadRequest()); // Verify rollback: no records should be persisted - assertEquals(0, knownPidsDao.count()); + assertEquals(0, knownPidsDao.count(), "Rollback should prevent any records from being stored due to mixed valid and invalid records"); } @Test @@ -951,6 +983,6 @@ void testRecordCreationFailureWithDuplicatePids() throws Exception { .andExpect(MockMvcResultMatchers.status().isBadRequest()); // Verify no records were persisted - assertEquals(0, knownPidsDao.count()); + assertEquals(0, knownPidsDao.count(), "Rollback should prevent any records from being stored due to duplicate PIDs"); } } \ No newline at end of file From 4e168d71c63d67ac93b081e96a20c6c5db9c086f Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Tue, 15 Jul 2025 17:36:34 +0200 Subject: [PATCH 104/108] Improved JavaDoc Signed-off-by: Maximilian Inckmann --- .../kit/datamanager/pit/web/PIDBuilder.java | 185 +++++++++ .../datamanager/pit/web/PIDRecordBuilder.java | 385 +++++++++++++++--- 2 files changed, 504 insertions(+), 66 deletions(-) diff --git a/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java b/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java index c576e1d2..0c9822c7 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java +++ b/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java @@ -23,16 +23,78 @@ import java.util.Random; import java.util.UUID; +/** + * Builder class for creating Persistent Identifier (PID) strings. + * + *

This class facilitates the generation of PIDs in various formats for testing purposes. It can create + * valid PIDs conforming to standard patterns, as well as intentionally invalid PIDs for negative testing + * scenarios. The builder uses a seed-based approach to generate deterministic PIDs, allowing for + * reproducible test cases.

+ * + *

A PID consists of two parts separated by a slash: + *

    + *
  • prefix: typically identifies the naming authority or domain (e.g., "sandboxed")
  • + *
  • suffix: a unique identifier (typically a UUID)
  • + *
+ * + *

Example usage:

+ *
+ *   // Create a valid PID
+ *   PIDBuilder builder = new PIDBuilder();
+ *   String validPid = builder.build(); // Results in something like "sandboxed/550e8400-e29b-41d4-a716-446655440000"
+ *
+ *   // Create a PID with custom parts
+ *   String customPid = new PIDBuilder()
+ *       .withPrefix("test-authority")
+ *       .withSuffix("custom-id-123")
+ *       .build(); // Results in "test-authority/custom-id-123"
+ *
+ *   // Create an invalid PID for testing error handling
+ *   String invalidPid = new PIDBuilder()
+ *       .invalidCharactersPrefix()
+ *       .build();
+ * 
+ */ public class PIDBuilder implements Cloneable { + /** + * The seed value used for the random generator to create deterministic PIDs. + * This enables reproducible test scenarios with consistent PID generation. + */ Long seed; + + /** + * Random number generator initialized with the seed value. + * Used for generating random components of PIDs in a deterministic way. + */ private Random random; + + /** + * The prefix part of the PID, representing the naming authority or domain. + * For example, "sandboxed" or "0.NA". + */ private String prefix; + + /** + * The suffix part of the PID, representing the unique identifier. + * Typically a UUID or other unique string. + */ private String suffix; + /** + * Creates a new PIDBuilder with a random seed. + * The builder is initialized with valid default prefix and suffix values. + */ public PIDBuilder() { this(new Random().nextLong()); } + /** + * Creates a new PIDBuilder with the specified seed. + * The builder is initialized with valid default prefix and suffix values. + * Using a specific seed allows for reproducible PID generation across test runs. + * + * @param seed The seed value for the random generator + */ public PIDBuilder(Long seed) { this.seed = seed; this.random = new Random(seed); @@ -42,6 +104,15 @@ public PIDBuilder(Long seed) { this.validSuffix(); } + /** + * Generates a deterministic UUID based on a seed string. + * This method uses SHA-1 to hash the seed string and constructs a UUID from the hash. + * The resulting UUID is consistent for the same input seed. + * + * @param seed The seed string to generate the UUID from + * @return A UUID derived from the hash of the seed string + * @throws RuntimeException If the SHA-1 algorithm is not available + */ private static UUID generateUUID(String seed) { try { MessageDigest md = MessageDigest.getInstance("SHA-1"); @@ -60,21 +131,48 @@ private static UUID generateUUID(String seed) { } } + /** + * Sets a new seed value for this builder and reinitializes the random generator. + * This allows changing the deterministic behavior of the builder after creation. + * + * @param seed The new seed value + * @return This builder instance for method chaining + */ public PIDBuilder withSeed(Long seed) { this.seed = seed; this.random = new Random(seed); return this; } + /** + * Sets a custom prefix for the PID. + * The prefix typically represents the naming authority or domain. + * + * @param prefix The prefix string to use + * @return This builder instance for method chaining + */ public PIDBuilder withPrefix(String prefix) { this.prefix = prefix; return this; } + /** + * Builds the final PID string by combining the prefix and suffix with a separator. + * The resulting format is "prefix/suffix". + * + * @return The complete PID string + */ public String build() { return prefix + "/" + suffix; } + /** + * Copies all properties from another PIDBuilder instance to this one. + * This method changes the current builder to match the state of the provided builder. + * + * @param builder The source PIDBuilder to copy from + * @return This builder instance for method chaining + */ public PIDBuilder clone(PIDBuilder builder) { this.seed = builder.seed; this.random = new Random(seed); @@ -83,21 +181,48 @@ public PIDBuilder clone(PIDBuilder builder) { return this; } + /** + * Sets a valid prefix for testing purposes. + * The default valid prefix is "sandboxed", which is used for test environments. + * + * @return This builder instance for method chaining + */ public PIDBuilder validPrefix() { this.prefix = "sandboxed"; return this; } + /** + * Sets a prefix that would cause authorization issues in a real environment. + * The prefix "0.NA" is typically reserved and would require special permissions. + * This is useful for testing authorization error handling. + * + * @return This builder instance for method chaining + */ public PIDBuilder unauthorizedPrefix() { this.prefix = "0.NA"; return this; } + /** + * Sets an empty prefix, which would result in an invalid PID format. + * This is useful for testing validation error handling. + * + * @return This builder instance for method chaining + */ public PIDBuilder emptyPrefix() { this.prefix = ""; return this; } + /** + * Generates a prefix containing invalid characters. + * Valid prefixes should match the regex pattern: ^[a-zA-Z0-9.-]+$ + * This method generates a string with random characters that deliberately + * fail this validation, which is useful for testing error handling. + * + * @return This builder instance for method chaining + */ public PIDBuilder invalidCharactersPrefix() { // generate a random String not fulfilling this regex: ^[a-zA-Z0-9.-]+$ StringBuilder result = new StringBuilder(); @@ -113,11 +238,25 @@ public PIDBuilder invalidCharactersPrefix() { return this; } + /** + * Sets a custom suffix for the PID. + * The suffix is the unique identifier part of the PID. + * + * @param suffix The suffix string to use + * @return This builder instance for method chaining + */ public PIDBuilder withSuffix(String suffix) { this.suffix = suffix; return this; } + /** + * Generates a valid suffix using a UUID derived from the current seed. + * This ensures that the suffix is both valid and deterministic based on the seed value. + * The generated UUID follows the standard format (e.g., "550e8400-e29b-41d4-a716-446655440000"). + * + * @return This builder instance for method chaining + */ public PIDBuilder validSuffix() { // generate a UUID based on the seed UUID uuid = generateUUID(seed.toString()); @@ -125,11 +264,29 @@ public PIDBuilder validSuffix() { return this; } + /** + * Sets an empty suffix, which would result in an invalid PID format. + * This is useful for testing validation error handling. + * + * @return This builder instance for method chaining + */ public PIDBuilder emptySuffix() { this.suffix = ""; return this; } + /** + * Generates a suffix containing invalid characters. + * Valid suffixes should match the regex pattern: ^[a-f0-9-]+$ + * This method attempts to generate a string that doesn't match this pattern, + * which is useful for testing validation error handling. + * + *

Note: The implementation appears to have a logical error as it generates + * characters that DO match the pattern rather than characters that DON'T match it. + * The condition should be reversed for correct behavior.

+ * + * @return This builder instance for method chaining + */ public PIDBuilder invalidCharactersSuffix() { // generate a random String not fulfilling this regex: ^[a-f0-9-]+$ StringBuilder result = new StringBuilder(); @@ -145,6 +302,12 @@ public PIDBuilder invalidCharactersSuffix() { return this; } + /** + * Returns a string representation of this PIDBuilder instance. + * Includes the prefix, suffix, and seed values for debugging purposes. + * + * @return A string representation of this builder + */ @Override public String toString() { return "PIDBuilder{" + @@ -154,6 +317,14 @@ public String toString() { '}'; } + /** + * Compares this PIDBuilder to another object for equality. + * Two PIDBuilder instances are considered equal if they have the same prefix and suffix. + * Note that the seed value is intentionally not considered in the equality check. + * + * @param o The object to compare with + * @return true if the objects are equal, false otherwise + */ @Override public final boolean equals(Object o) { if (!(o instanceof PIDBuilder that)) return false; @@ -161,6 +332,12 @@ public final boolean equals(Object o) { return Objects.equals(prefix, that.prefix) && Objects.equals(suffix, that.suffix); } + /** + * Returns a hash code value for this PIDBuilder. + * The hash code is based on the prefix and suffix values, consistent with the equals method. + * + * @return The hash code value + */ @Override public int hashCode() { int result = Objects.hashCode(prefix); @@ -168,6 +345,14 @@ public int hashCode() { return result; } + /** + * Creates a deep clone of this PIDBuilder instance. + * The cloned builder will have the same seed, prefix, and suffix values, + * but with a new random generator instance initialized with the same seed. + * + * @return A new PIDBuilder instance with the same properties + * @throws AssertionError If cloning fails, which should never happen since PIDBuilder implements Cloneable + */ @Override public PIDBuilder clone() { try { diff --git a/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java b/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java index b8ff076f..93f22a55 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java +++ b/src/test/java/edu/kit/datamanager/pit/web/PIDRecordBuilder.java @@ -22,40 +22,142 @@ import java.time.temporal.ChronoUnit; import java.util.*; +/** + * Builder class for creating PIDRecord instances with various configurations for testing purposes. + * + *

This class facilitates the generation of PID records with specific properties, connections to other records, + * and the addition of metadata. + * It is primarily designed for testing scenarios where different types of + * PID records are needed, including both valid and invalid configurations.

+ * + *

Key features:

+ *
    + *
  • Create records with valid or invalid data
  • + *
  • Connect multiple records in various relationship patterns
  • + *
  • Manage the internal state of records
  • + *
  • Add metadata, connections, PIDs, and generate invalid values or keys
  • + *
  • Create records that conform to or violate specific profiles
  • + *
  • Support for deterministic record generation using seeds
  • + *
+ * + *

This builder relies on the Helmholtz Kernel Information Profile + * (21.T11148/301c6f04763a16f0f72a) + * and its data types for validation and structure of the records.

+ * + *

Example usage:

+ *
+ *     // Create a basic valid record
+ *     PIDRecordBuilder builder = new PIDRecordBuilder();
+ *     PIDRecord record = builder.withPid("21.T11148/1234567890abcdef")
+ *        .completeProfile()
+ *        .build();
+ *
+ *     // Create connected records
+ *     PIDRecordBuilder builder1 = new PIDRecordBuilder();
+ *     PIDRecordBuilder builder2 = new PIDRecordBuilder();
+ *     builder1.addConnection("21.T11148/d0773859091aeb451528", true, builder2);
+ *
+ *     // Create an invalid record for testing error handling
+ *     PIDRecord invalidRecord = new PIDRecordBuilder()
+ *        .invalidKeys(3)
+ *        .invalidValues(2)
+ *        .build();
+ * 
+ */ public class PIDRecordBuilder implements Cloneable { + /** + * Represents the current time plus one minute, truncated to milliseconds. + * Used for setting timestamps in record metadata to ensure they are in the future. + */ private static final Instant NOW = Instant.now().plus(1, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS); + + /** + * Represents the time 24 hours before NOW, truncated to milliseconds. + * Used for setting creation dates in record metadata to ensure they are before modification dates. + */ private static final Instant YESTERDAY = NOW.minus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); - private static final Instant TOMORROW = NOW.plus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS); + + /** + * PID key that identifies the profile type in a record. + * This key is used to specify which profile the record conforms to. + */ private static final String PROFILE_KEY = "21.T11148/076759916209e5d62bd5"; + + /** + * PID key used for establishing "hasMetadata" relationships between records. + * This is used when one record references metadata contained in another record. + */ private static final String HAS_METADATA_KEY = "21.T11148/d0773859091aeb451528"; + + /** + * PID key used for establishing "isMetadataFor" relationships between records. + * This is the inverse relationship of HAS_METADATA_KEY. + */ private static final String IS_METADATA_FOR_KEY = "21.T11148/4fe7cde52629b61e3b82"; + + /** + * List of standardized PID keys that are required by the Helmholtz Kernel Information Profile. + * These keys represent mandatory metadata fields that must be present in a valid record: + * - dateCreated + * - digitalObjectPolicy + * - etag + * - dateModified + * - digitalObjectLocation + * - version + * - digitalObjectType + */ private static final List KEYS_IN_PROFILE = new ArrayList<>(Arrays.stream(new String[]{"21.T11148/397d831aa3a9d18eb52c", "21.T11148/8074aed799118ac263ad", "21.T11148/92e200311a56800b3e47", "21.T11148/aafd5fb4c7222e2d950a", "21.T11148/b8457812905b83046284", "21.T11148/c692273deb2772da307f", "21.T11148/c83481d4bf467110e7c9"}).toList()); + /** + * The seed value used for the random generator to create deterministic records. + * This enables reproducible test scenarios with consistent record generation. + */ Long seed; + + /** + * Random number generator initialized with the seed value. + * Used for generating random components of records in a deterministic way. + */ private Random random; + + /** + * The PID record being built by this builder. + * All operations performed on this builder modify this record instance. + */ private PIDRecord record; /** - * Create a PID record builder with a new PID builder and a new seed. + * Creates a new PIDRecordBuilder with default settings. + * Initializes a new record with a randomly generated PID using a new random seed. + * This is the simplest way to create a builder for basic test cases. */ public PIDRecordBuilder() { this(null); } /** - * Create a PID record builder with a given PID builder. If the PID builder is null, a valid PID is generated. + * Creates a new PIDRecordBuilder using the specified PID builder. + * If the provided PID builder is null, a default valid PID is generated. + * This constructor allows control over how the PID for the record is generated. * - * @param pidBuilder PID builder to use to generate a PID for the record + * @param pidBuilder PID builder to use to generate a PID for the record, or null to use a default */ public PIDRecordBuilder(PIDBuilder pidBuilder) { this(pidBuilder, null); } /** - * Create a PID record builder with a given seed and a PID builder. If the PID builder is null, a valid PID is generated. + * Creates a new PIDRecordBuilder with the specified PID builder and seed value. + * This constructor provides full control over both the PID generation and the randomization seed. + * + *

+ * If the seed is null, a random seed will be generated. + * If the PID builder is null, + * a default builder will be created using the seed value to ensure deterministic PID generation. + *

* - * @param pidBuilder PID builder to use to generate a PID for the record - * @param seed seed for the random generator + * @param pidBuilder PID builder to use to generate a PID for the record, or null to use a default + * @param seed Seed value for the random generator, or null to generate a random seed */ public PIDRecordBuilder(PIDBuilder pidBuilder, Long seed) { this.seed = seed != null ? seed : new Random().nextLong(); @@ -69,16 +171,25 @@ public PIDRecordBuilder(PIDBuilder pidBuilder, Long seed) { } /** - * This function connects multiple PIDRecordBuilders with each other. The builders are connected in a fully meshed way. - * - * @param a_to_b_key The key to use for the connection from A to B (forwards). - * If null, the default key "21.T11148/d0773859091aeb451528" (hasMetadata) is used. - * @param b_to_a_key The key to use for the connection from B to A (backwards). - * If null, the default key "21.T11148/4fe7cde52629b61e3b82" (isMetadataFor) is used. - * @param allowDuplicateRelations If true, duplicate relations are allowed in the record, so that multiple connections can be established between the same builders. - * @param builders A list of PIDRecordBuilders to connect - * @return A list of connected PIDRecordBuilders - * @throws IllegalArgumentException If less than two builders are given + * Creates bidirectional connections between multiple PIDRecordBuilders in a fully meshed network. + * + *

This static method establishes connections between all provided builders, creating + * a network where every builder is connected to every other builder. For each pair of builders, + * two connections are established: one in each direction, using the specified keys.

+ * + *

This is particularly useful for testing complex relationship networks between PID records, + * such as metadata relationships, hierarchical structures, or cross-references.

+ * + * @param a_to_b_key The key to use for forward connections (from A to B). + * If null, the default "hasMetadata" key is used. + * @param b_to_a_key The key to use for backward connections (from B to A). + * If null, the default "isMetadataFor" key is used. + * @param allowDuplicateRelations Whether to allow multiple connections between the same builders. + * This controls the behavior when adding connections that might already exist. + * @param builders Array of PIDRecordBuilders to connect in the network. + * Must contain at least two builders. + * @return A list containing all the connected builders + * @throws IllegalArgumentException If fewer than two builders are provided */ public static List connectRecordBuilders(String a_to_b_key, String b_to_a_key, boolean allowDuplicateRelations, PIDRecordBuilder... builders) throws IllegalArgumentException { if (builders.length < 2) { @@ -101,21 +212,33 @@ public static List connectRecordBuilders(String a_to_b_key, St } /** - * Build the PID record. - * The record is cloned before it is returned. - * This means that the builder can be used to build multiple records. + * Builds and returns the final PID record. + * + *

This method returns a clone of the internal record, which means the builder can be used + * to create multiple records with different modifications without affecting previously built records.

* - * @return the cloned PID record + *

Example:

+ *
+     *   PIDRecordBuilder builder = new PIDRecordBuilder();
+     *   PIDRecord record1 = builder.completeProfile().build(); // A valid record
+     *   PIDRecord record2 = builder.invalidKeys(2).build();   // An invalid record
+     * 
+ * + * @return A new clone of the built PID record */ public PIDRecord build() { return this.record.clone(); } /** - * Set the seed for the random generator. + * Sets a new seed value for this builder and reinitializes the random generator. + * + *

This method allows changing the deterministic behavior of the builder after creation. + * All later random operations will use the new seed, making test results reproducible + * when the same seed is used.

* - * @param seed seed to set - * @return this builder + * @param seed The new seed value for random operations + * @return This builder instance for method chaining */ public PIDRecordBuilder withSeed(Long seed) { this.seed = seed; @@ -124,10 +247,14 @@ public PIDRecordBuilder withSeed(Long seed) { } /** - * Set the record to a given PID. + * Sets the PID (Persistent Identifier) of the record being built. * - * @param pid PID to set - * @return this builder + *

This method allows specifying a custom PID instead of using the automatically generated one. + * This is useful when you need to test with specific, known PIDs or when creating records that + * need to match existing identifiers.

+ * + * @param pid The PID string to assign to the record + * @return This builder instance for method chaining */ public PIDRecordBuilder withPid(String pid) { this.record.setPid(pid); @@ -135,13 +262,22 @@ public PIDRecordBuilder withPid(String pid) { } /** - * This method adds a connection to another PID record to the current record. + * Adds connections from this record to one or more other records using a specified relationship key. + * + *

This method establishes directional connections from the current record to each of the specified + * target records. Each connection is made using the provided key, which defines the relationship type.

+ * + *

Common relationship types include:

+ *
    + *
  • "21.T11148/d0773859091aeb451528" - hasMetadata relationship
  • + *
  • "21.T11148/4fe7cde52629b61e3b82" - isMetadataFor relationship
  • + *
* - * @param key key to use for the connection - * @param builders list of PIDRecordBuilders to connect to - * @param replaceIdentical if true, replace the connection if it already exists - * @return this builder - * @throws IllegalArgumentException if no builders are given + * @param key The key defining the relationship type for the connections + * @param replaceIdentical Whether to replace existing connections with the same key and target + * @param builders The target PIDRecordBuilders to connect to (at least one is required) + * @return This builder instance for method chaining + * @throws IllegalArgumentException If no target builders are provided */ public PIDRecordBuilder addConnection(String key, boolean replaceIdentical, PIDRecordBuilder... builders) throws IllegalArgumentException { if (builders.length == 0) { @@ -155,10 +291,14 @@ public PIDRecordBuilder addConnection(String key, boolean replaceIdentical, PIDR } /** - * Set the record to a given PID record. + * Sets the internal record to a specified PIDRecord instance. * - * @param record PID record to set - * @return this builder + *

This method replaces the current record being built with the provided record instance. + * This is useful when you want to start with an existing record and make modifications to it, + * or when you need to restore a builder to a previous state.

+ * + * @param record The PIDRecord to use as the basis for further building + * @return This builder instance for method chaining */ public PIDRecordBuilder withPIDRecord(PIDRecord record) { this.record = record; @@ -166,9 +306,24 @@ public PIDRecordBuilder withPIDRecord(PIDRecord record) { } /** - * Add valid keys and values to the record that fulfill a predefined profile. If this is the first build step after construction, the PID record is valid. + * Adds all the required keys and values to make the record conform to the Helmholtz Kernel Information Profile. + * + *

This method populates the record with a complete set of valid metadata entries that fulfill + * the requirements of the profile. After calling this method, the record will be valid, according + * to the profile specifications. The added entries include:

* - * @return this builder (with valid PID record) + *
    + *
  • Profile identifier
  • + *
  • Creation date (set to yesterday)
  • + *
  • Digital object policy
  • + *
  • ETag for versioning
  • + *
  • Modification date (set to the current time plus one minute)
  • + *
  • Digital object location (a generated URL)
  • + *
  • Version information
  • + *
  • Digital object type
  • + *
+ * + * @return This builder instance for method chaining, now with a valid profile-compliant record */ public PIDRecordBuilder completeProfile() { this.addNotDuplicate(PROFILE_KEY, "21.T11148/301c6f04763a16f0f72a", "KernelInformationProfile", true); @@ -183,11 +338,17 @@ public PIDRecordBuilder completeProfile() { } /** - * This method removes a random (mandatory) key from the profile. - * The profile is incomplete afterward. - * If nothing is in the record, the profile is, per definition, not fulfilled. + * Creates an intentionally incomplete profile by removing mandatory keys. + * + *

This method first sets a non-standard profile key and then removes all standard profile keys + * from the record. The resulting record will intentionally fail validation against the standard + * profile, which is useful for testing error handling and validation logic.

+ * + *

Note that even an empty record is, by definition, considered incomplete with respect to + * the profile requirements. This method ensures the record specifically indicates it follows + * a different profile than the standard one.

* - * @return this builder + * @return This builder instance for method chaining, now with an incomplete profile */ public PIDRecordBuilder incompleteProfile() { this.addNotDuplicate(PROFILE_KEY, "21.T11148/b9b76f887845e32d29f7", "KernelInformationProfile", true); @@ -202,15 +363,25 @@ public PIDRecordBuilder incompleteProfile() { } /** - * Add a given number of invalid values to the record. - * If the amount is smaller or equal to zero and no keys are specified, invalid values for a predefined list of valid keys (currently length 7) are generated. - * If you specify keys, the invalid values are generated for these keys. - * If the amount is greater than the number of keys, the remaining invalid values are generated for randomly selected keys from the predefined list. - * If the amount is greater than zero and no keys are specified, invalid values are generated for all predefined keys which are randomly repeated until the amount is reached. - * - * @param amount number of invalid values to add (optional) - * @param keys keys for which invalid values should be generated (optional) - * @return this builder + * Adds a specified number of invalid values to the record for testing validation failure scenarios. + * + *

This method allows fine-grained control over which keys receive invalid values and how many + * invalid values are added. It can generate invalid values for specific keys or randomly select + * keys from the standard profile.

+ * + *

The behavior depends on the provided parameters:

+ *
    + *
  • If amount ≤ 0 and keys are specified: Generate invalid values for all specified keys
  • + *
  • If amount > 0 and keys.length ≥ amount: Generate invalid values for the first 'amount' keys
  • + *
  • If amount > 0 and keys.length < amount: Generate invalid values for all specified keys plus + * randomly selected keys from the profile until reaching 'amount'
  • + *
  • If amount ≤ 0 and no keys specified: Generate invalid values for all keys in the profile
  • + *
  • If amount > 0 and no keys specified: Generate 'amount' invalid values for randomly selected keys
  • + *
+ * + * @param amount The number of invalid values to add (use 0 or negative for special behavior) + * @param keys Optional specific keys for which to generate invalid values + * @return This builder instance for method chaining */ public PIDRecordBuilder invalidValues(int amount, String... keys) { List keysToGenerateValuesFor = new ArrayList<>(); @@ -239,10 +410,18 @@ public PIDRecordBuilder invalidValues(int amount, String... keys) { } /** - * Add a given amount of invalid keys to the record. The keys are generated randomly. + * Adds a specified number of randomly generated invalid keys to the record. + * + *

This method creates nonsensical, randomly generated keys and adds them to the record + * with random string values. This is useful for testing how systems handle records with + * unexpected or malformed keys.

+ * + *

The generated keys have the prefix "invalid-key-" followed by a random string of + * characters with a length between 5 and 256. Each key is assigned a random value string + * of 16 characters.

* - * @param amount amount of invalid keys to add - * @return this builder + * @param amount The number of invalid keys to add to the record + * @return This builder instance for method chaining */ public PIDRecordBuilder invalidKeys(int amount) { for (int i = 0; i < amount; i++) { @@ -252,20 +431,31 @@ public PIDRecordBuilder invalidKeys(int amount) { } /** - * Set the record to an empty record. The PID is not changed. All entries are removed. + * Removes all entries from the record, creating an empty record while preserving the PID. * - * @return this builder + *

This method is useful for testing how systems handle empty records or for creating + * a clean slate before adding specific entries. The PID of the record is maintained, + * but all metadata entries are removed.

+ * + * @return This builder instance for method chaining */ public PIDRecordBuilder emptyRecord() { String pid = this.record.getPid(); - this.record = new PIDRecord(); + this.record = new PIDRecord().withPID(pid); return this; } /** - * Set the record to null. + * Sets the internal record reference to null. * - * @return this builder + *

This method creates an invalid state where the builder has no record to work with. + * This is primarily useful for testing null-handling and error recovery in code that uses + * this builder.

+ * + *

Note: After calling this method, most other methods that operate on the record will + * likely throw NullPointerExceptions if called.

+ * + * @return This builder instance for method chaining */ public PIDRecordBuilder nullRecord() { this.record = null; @@ -273,11 +463,20 @@ public PIDRecordBuilder nullRecord() { } /** - * Add an entry to the record if it does not exist yet. + * Adds an entry to the record, with controls for handling duplicate entries. * - * @param key key of the entry - * @param value value of the entry - * @param replace if true, replace the value of the entry if it already exists, even if it is a list of values + *

This method adds a key-value entry to the record. If the key already exists in the record, + * the behavior depends on the replace parameter:

+ *
    + *
  • If replace is true: Any existing values for the key are removed before adding the new value
  • + *
  • If replace is false: The new value is added alongside existing values (may create duplicates)
  • + *
+ * + * @param key The key identifier for the entry + * @param value The value to associate with the key + * @param name The human-readable name for the entry type + * @param replace Whether to replace existing values for the key + * @return This builder instance for method chaining */ public PIDRecordBuilder addNotDuplicate(String key, String value, String name, boolean replace) { if (this.record.getEntries().containsKey(key) && replace) { @@ -288,10 +487,17 @@ public PIDRecordBuilder addNotDuplicate(String key, String value, String name, b } /** - * Generate a random string of a given length. + * Generates a random string of the specified length. + * + *

This utility method creates a string of random characters, using the full range of + * possible character values up to Character.MAX_VALUE. This can include characters from + * any Unicode block, control characters, surrogate pairs, etc.

* - * @param length length of the string - * @return random string + *

Note that the resulting string may contain characters that are not displayable + * in all contexts or may cause issues with certain text processing systems.

+ * + * @param length The length of the random string to generate + * @return A string of random characters with the specified length */ private String generateRandomString(int length) { StringBuilder result = new StringBuilder(); @@ -302,6 +508,18 @@ private String generateRandomString(int length) { return result.toString(); } + /** + * Compares this PIDRecordBuilder to another object for equality. + * + *

Two PIDRecordBuilder instances are considered equal if they have equivalent records. + * Note that the seed and random generator state are not considered in the equality check, + * only the record content itself.

+ * + *

This method is marked as final to prevent subclasses from changing the equality semantics.

+ * + * @param o The object to compare with + * @return true if the objects are equal, false otherwise + */ @Override public final boolean equals(Object o) { if (!(o instanceof PIDRecordBuilder that)) return false; @@ -309,11 +527,37 @@ public final boolean equals(Object o) { return Objects.equals(record, that.record); } + /** + * Returns a hash code value for this PIDRecordBuilder. + * + *

The hash code is based solely on the record's hash code, consistent with the equals method. + * This ensures that equal builders have the same hash code, as required by the general contract + * of the Object.hashCode method.

+ * + * @return The hash code value + */ @Override public int hashCode() { return Objects.hashCode(record); } + /** + * Creates a deep clone of this PIDRecordBuilder. + * + *

This method creates a new PIDRecordBuilder instance with the same properties as this one, + * including:

+ *
    + *
  • The same seed value
  • + *
  • A new random generator initialized with the same seed
  • + *
  • A deep clone of the record being built
  • + *
+ * + *

This allows for creating independent copies of the builder that can be modified separately + * without affecting each other, while still maintaining the same initial state.

+ * + * @return A new PIDRecordBuilder instance with the same properties + * @throws AssertionError If cloning fails, which should never happen since PIDRecordBuilder implements Cloneable + */ @Override public PIDRecordBuilder clone() { try { @@ -327,6 +571,15 @@ public PIDRecordBuilder clone() { } } + /** + * Returns a string representation of this PIDRecordBuilder. + * + *

The string includes the seed value, a reference to the random generator, + * and the string representation of the record being built. This is primarily + * useful for debugging and logging purposes.

+ * + * @return A string representation of this builder + */ @Override public String toString() { return "PIDRecordBuilder{" + From b66c1468eee5c4ea9f93acf90938b7bc67070ebd Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Tue, 15 Jul 2025 18:11:35 +0200 Subject: [PATCH 105/108] minor fixes Signed-off-by: Maximilian Inckmann --- .../kit/datamanager/pit/domain/PIDRecord.java | 5 +++++ .../datamanager/pit/domain/PIDRecordEntry.java | 6 +----- .../datamanager/pit/web/BatchRecordResponse.java | 5 ++--- .../pit/web/impl/TypingRESTResourceImpl.java | 16 ++++++++++++---- .../datamanager/pit/web/ConnectedPIDsTest.java | 2 +- .../edu/kit/datamanager/pit/web/PIDBuilder.java | 10 +++++----- 6 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java index 47ac098a..4edac653 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecord.java @@ -85,6 +85,11 @@ public Map> getEntries() { return entries; } + /** + * Sets the entries of this record. + * + * @param entries the entries to set. + */ public void setEntries(Map> entries) { this.entries = entries; } diff --git a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java index 64f322e7..bacd2884 100644 --- a/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java +++ b/src/main/java/edu/kit/datamanager/pit/domain/PIDRecordEntry.java @@ -27,11 +27,7 @@ public class PIDRecordEntry implements Cloneable { @Override public PIDRecordEntry clone() { try { - PIDRecordEntry clone = (PIDRecordEntry) super.clone(); - clone.setKey(this.key); - clone.setName(this.name); - clone.setValue(this.value); - return clone; + return (PIDRecordEntry) super.clone(); } catch (CloneNotSupportedException e) { throw new AssertionError(); } diff --git a/src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java b/src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java index f8a3fa11..74dd0641 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java +++ b/src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java @@ -26,10 +26,9 @@ * Contains a list of the processed PID records and a mapping of the user-provided "fictionary" identifiers to their corresponding real record Handle PIDs. * This mapping was used to link the user-provided identifiers with the actual records in the system. *

- * Arguments: - * - pidRecords: List of PIDRecord objects representing the processed records. (List) - * - mapping: Map where keys are user-provided identifiers (fictionary) and values are the corresponding real record Handle PIDs. (Map) * + * @param pidRecords List of PIDRecord objects representing the processed records. (List) + * @param mapping Map where keys are user-provided identifiers (fictionary) and values are the corresponding real record Handle PIDs. (Map) * @see PIDRecord */ public record BatchRecordResponse(List pidRecords, Map mapping) { diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index 9a518294..64a6f635 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -134,6 +134,7 @@ public ResponseEntity createPIDs( } List failedRecords = new ArrayList<>(); + List successfulRecords = new ArrayList<>(); // register the records validatedRecords.forEach(pidRecord -> { try { @@ -160,10 +161,11 @@ public ResponseEntity createPIDs( // save the record to elastic this.saveToElastic(pidRecord); + successfulRecords.add(pidRecord); + LOG.debug("Successfully registered PID for record: {}", pidRecord); } catch (Exception e) { LOG.error("Could not register PID for record {}. Error: {}", pidRecord, e.getMessage()); failedRecords.add(pidRecord); - validatedRecords.remove(pidRecord); } }); @@ -176,20 +178,26 @@ public ResponseEntity createPIDs( LOG.info("-- Time taken for registration: {} ms", ChronoUnit.MILLIS.between(validationTime, endTime)); if (!failedRecords.isEmpty()) { - for (PIDRecord successfulRecord : validatedRecords) { // rollback the successful records + List rollbackFailures = new ArrayList<>(); + for (PIDRecord successfulRecord : successfulRecords) { // rollback the successful records try { LOG.debug("Rolling back PID creation for record with PID {}.", successfulRecord.getPid()); this.typingService.deletePid(successfulRecord.getPid()); } catch (Exception e) { + rollbackFailures.add(successfulRecord.getPid()); LOG.error("Could not rollback PID creation for record with PID {}. Error: {}", successfulRecord.getPid(), e.getMessage()); } } + if (!rollbackFailures.isEmpty()) { + LOG.error("Failed to rollback {} PIDs: {}", rollbackFailures.size(), rollbackFailures); + } + LOG.info("Creation finished. Returning validated records for {} records. {} records failed to be created.", validatedRecords.size(), failedRecords.size()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new BatchRecordResponse(failedRecords, pidMappings)); } else { - LOG.info("Creation finished. Returning validated records for {} records.", validatedRecords.size()); - return ResponseEntity.status(HttpStatus.CREATED).body(new BatchRecordResponse(validatedRecords, pidMappings)); + LOG.info("Creation finished. Returning successfully validated and created records for {} records of {}.", successfulRecords.size(), validatedRecords.size()); + return ResponseEntity.status(HttpStatus.CREATED).body(new BatchRecordResponse(successfulRecords, pidMappings)); } } diff --git a/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java b/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java index 7a4de960..7063e900 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java @@ -147,7 +147,7 @@ void testPIDBuilderAllMethods() { // Test clone(PIDBuilder) method PIDBuilder targetBuilder = new PIDBuilder(); - targetBuilder.clone(originalBuilder); + targetBuilder.copyFrom(originalBuilder); assertEquals(originalBuilder.build(), targetBuilder.build(), "Target builder should produce the same PID after cloning"); // Test equals and hashCode diff --git a/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java b/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java index 0c9822c7..e6ca3bbb 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java +++ b/src/test/java/edu/kit/datamanager/pit/web/PIDBuilder.java @@ -115,7 +115,7 @@ public PIDBuilder(Long seed) { */ private static UUID generateUUID(String seed) { try { - MessageDigest md = MessageDigest.getInstance("SHA-1"); + MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] hash = md.digest(seed.getBytes(StandardCharsets.UTF_8)); long msb = 0; long lsb = 0; @@ -173,7 +173,7 @@ public String build() { * @param builder The source PIDBuilder to copy from * @return This builder instance for method chaining */ - public PIDBuilder clone(PIDBuilder builder) { + public PIDBuilder copyFrom(PIDBuilder builder) { this.seed = builder.seed; this.random = new Random(seed); this.prefix = builder.prefix; @@ -288,14 +288,14 @@ public PIDBuilder emptySuffix() { * @return This builder instance for method chaining */ public PIDBuilder invalidCharactersSuffix() { - // generate a random String not fulfilling this regex: ^[a-f0-9-]+$ + // generate a random String not fulfilling this regex: ^[a-zA-Z0-9.-]+$ StringBuilder result = new StringBuilder(); for (int i = 0; i < random.nextInt(256); i++) { // Random length - // generate a random character that is not a letter, number or hyphen + // generate a random character that is not a letter, number, dot or hyphen char c; do { c = (char) random.nextInt(Character.MAX_VALUE); // Random character - } while (Character.digit(c, 16) == -1 && c != '-'); // Continue until an invalid character is found + } while (Character.isLetterOrDigit(c) || c == '.' || c == '-'); // Continue until an invalid character is found result.append(c); } this.suffix = result.toString(); From d1e078276d44a57495cadec7f11682a6536a56b6 Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Tue, 15 Jul 2025 19:03:23 +0200 Subject: [PATCH 106/108] minor fixes to the Swagger API docs Signed-off-by: Maximilian Inckmann --- build.gradle | 44 +++-- config/application-default.properties | 171 +++++++----------- .../pit/web/BatchRecordResponse.java | 4 +- .../pit/web/ITypingRestResource.java | 9 +- .../pit/web/impl/TypingRESTResourceImpl.java | 42 ++--- 5 files changed, 127 insertions(+), 143 deletions(-) diff --git a/build.gradle b/build.gradle index 06a36087..659f134d 100644 --- a/build.gradle +++ b/build.gradle @@ -48,7 +48,7 @@ repositories { } ext { - springDocVersion = '2.8.4' + springDocVersion = '2.8.9' } dependencies { @@ -82,15 +82,15 @@ dependencies { // More flexibility when (de-)serializing json: //implementation("com.monitorjbl:spring-json-view:1.1.0") - implementation("com.github.everit-org.json-schema:org.everit.json.schema:1.14.4") - + implementation("com.github.everit-org.json-schema:org.everit.json.schema:1.14.4") + implementation('org.apache.httpcomponents:httpclient:4.5.14') implementation('org.apache.httpcomponents:httpclient-cache:4.5.14') implementation("net.handle:handle-client:9.3.1") testImplementation(platform('org.junit:junit-bom:5.11.4')) - testImplementation('org.junit.jupiter:junit-jupiter') + testImplementation('org.junit.jupiter:junit-jupiter') testImplementation('org.junit.jupiter:junit-jupiter-params') testImplementation("org.springframework:spring-test") @@ -105,6 +105,22 @@ application { mainClass = 'edu.kit.datamanager.pit.Application' } +/* + * Copyright (c) 2025 Karlsruhe Institute of Technology. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + // Makes executable jar file. // Available function available through Spring Boot Gradle Plugin. bootJar { @@ -155,7 +171,7 @@ test { println "Tests will have verbose output" testLogging { // tests are never "up-to-date", always print everything - outputs.upToDateWhen {false} + outputs.upToDateWhen { false } // show stdio when tests are running showStandardStreams = true // for junit5 @@ -188,15 +204,15 @@ jacocoTestReport { afterEvaluate { //exclude some classes/package from code coverage report classDirectories.setFrom(files(classDirectories.files.collect { - fileTree(dir: it, exclude: [\ - 'edu/kit/datamanager/pit/configuration/**', \ - 'edu/kit/datamanager/pit/web/converter/**', \ - 'edu/kit/datamanager/pit/web/ExtendedErrorAttributes**', \ - 'edu/kit/datamanager/pit/web/UncontrolledExceptionHandler**', \ - 'edu/kit/datamanager/pit/common/**', \ - 'edu/kit/datamanager/pit/Application*' - ]) - })) + fileTree(dir: it, exclude: [\ + 'edu/kit/datamanager/pit/configuration/**', \ + 'edu/kit/datamanager/pit/web/converter/**', \ + 'edu/kit/datamanager/pit/web/ExtendedErrorAttributes**', \ + 'edu/kit/datamanager/pit/web/UncontrolledExceptionHandler**', \ + 'edu/kit/datamanager/pit/common/**', \ + 'edu/kit/datamanager/pit/Application*' + ]) + })) } } diff --git a/config/application-default.properties b/config/application-default.properties index 1ee66713..4e6f72a4 100644 --- a/config/application-default.properties +++ b/config/application-default.properties @@ -1,92 +1,84 @@ -# External configuration file for Typed PID Maker -# ----------------------------------------------- -# Regarding the location of this file, consider the default paths for Spring Boot configurations, documented here: -# https://docs.spring.io/spring-boot/reference/features/external-config.html#:~:text=config%20data%20files%20are%20considered%20in%20the%20following%20order%3A -# You can change the path with e.g. this command (if you use gradle to run it): -# ./gradlew run --args="--spring.config.location=config/application-default.properties" -# Or by passing the parameter directly to the jar file. # -# Documentation of common Spring Boot configuration properties (logging, ports, and others): -# https://docs.spring.io/spring-boot/appendix/application-properties/index.html -# More specific properties are documented within this file. - -### General Spring Boot Settings ### -# When to include "message" attribute in HTTP responses on uncatched exceptions. -server.error.include-message: always +# Copyright (c) 2025 Karlsruhe Institute of Technology. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +server.error.include-message=always +springdoc.api-docs.enabled=true +springdoc.swagger-ui.enabled=true springdoc.show-actuator=true # Do __not__ change these settings below: spring.main.allow-bean-definition-overriding=true -spring.data.rest.detection-strategy:annotated +spring.data.rest.detection-strategy=annotated ##################################################### - ########################### ### Port, SSL, Security ### ########################### - -server.port: 8090 +server.port=8090 #server.ssl.key-store: keystore.p12 #server.ssl.key-store-password: test123 #server.ssl.keyStoreType: PKCS12 #server.ssl.keyAlias: tomcat - -# Data transfer settings, e.g. transfer compression and multipart message size. +# Data transfer settings, e.g. transfer compression and multipart message size. # The properties max-file-size and max-request-size define the maximum size of files # transferred to and from the repository. Setting them to -1 removes all limits. -server.compression.enabled: false -spring.servlet.multipart.max-file-size: 100MB -spring.servlet.multipart.max-request-size: 100MB - -# *Generic* Spring Management Endpoint Settings. By default, the health endpoint will be +server.compression.enabled=false +spring.servlet.multipart.max-file-size=100MB +spring.servlet.multipart.max-request-size=100MB +# *Generic* Spring Management Endpoint Settings. By default, the health endpoint will be # enabled to apply service monitoring including detailed information. # Furthermore, all endpoints will be exposed to external access. If this is not desired, # just comment the property 'management.endpoints.web.exposure.include' in order to only # allow local access. -management.endpoint.health.access: unrestricted -management.endpoint.health.show-details: ALWAYS -management.endpoint.health.sensitive: false -management.endpoints.web.exposure.include: * - +management.endpoint.health.access=unrestricted +management.endpoint.health.show-details=ALWAYS +management.endpoint.health.sensitive=false +management.endpoints.web.exposure.include=* ############### ### Logging ### ############### - # Logging Settings. Most logging of KIT DM is performed on TRACE level. However, if you # plan to enable logging with this granularity it is recommended to this only for # a selection of a few packages. Otherwise, the amount of logging information might be # overwhelming. #logging.level.root: ERROR #logging.level.edu.kit.datamanager.doip:TRACE -logging.level.edu.kit: WARN +logging.level.edu.kit=WARN #logging.level.org.springframework.transaction: TRACE -logging.level.org.springframework: WARN -logging.level.org.springframework.amqp: WARN +logging.level.org.springframework=WARN +logging.level.org.springframework.amqp=WARN #logging.level.com.zaxxer.hikari: ERROR -logging.level.edu.kit.datamanager.pit.cli: INFO - +logging.level.edu.kit.datamanager.pit.cli=INFO ###################### ### Authentication ### ###################### - -# Enable/disable (default) authentication. If authentication is enabled, a separate +# Enable/disable (default) authentication. If authentication is enabled, a separate # Authentication Service should be used in order to obtain JSON Web Tokens holding # login information. The token has then to be provided within the Authentication header # of each HTTP request with a value of 'Bearer ' without quotes, replacing # be the token obtained from the authentication service. # A token needs a "username" in its payload. A minimal token therefore may look like this: # https://jwt.io/#debugger-io?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6InVzZXIifQ.pfZuRuxbj_izZlCnmotWHQuH00BJ35CbjpHILpuQU70 -repo.auth.enabled: false - +repo.auth.enabled=false # The jwtSecret is the mutual secret between all trusted services. This means, that if # authentication is enabled, the jwtSecret used by the Authentication Service to sign # issued JWTokens must be the same as the jwtSecret of the repository in order to # be able to validate the signature. By default, the secret should be selected randomly # and with a sufficient length. -repo.auth.jwtSecret: vkfvoswsohwrxgjaxipuiyyjgubggzdaqrcuupbugxtnalhiegkppdgjgwxsmvdb - +repo.auth.jwtSecret=vkfvoswsohwrxgjaxipuiyyjgubggzdaqrcuupbugxtnalhiegkppdgjgwxsmvdb ############################### ### Keycloak Authentication ### ############################### - spring.autoconfigure.exclude=org.keycloak.adapters.springboot.KeycloakAutoConfiguration,org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration #keycloakjwt.jwk-url=http://localhost:8080/auth/realms/myrealm/protocol/openid-connect/certs #keycloakjwt.resource=keycloak-angular @@ -94,36 +86,30 @@ spring.autoconfigure.exclude=org.keycloak.adapters.springboot.KeycloakAutoConfig ##keycloakjwt.connect-timeoutms=500 # optional ##keycloakjwt.read-timeoutms=500 # optional # -#keycloak.realm = myrealm -#keycloak.auth-server-url = http://localhost:8080/auth -#keycloak.resource = keycloak-angular - +#keycloak.realm=myrealm +#keycloak.auth-server-url=http://localhost:8080/auth +#keycloak.resource=keycloak-angular ############################################ ### Elastic Indexing and search endpoint ### ######## (requires Elasticsearch 8) ######## ############################################ - # enables search endpoint at /api/v1/search -repo.search.enabled: false -repo.search.index: * -management.health.elasticsearch.enabled: false - +repo.search.enabled=false +repo.search.index=* +management.health.elasticsearch.enabled=false # TO BE REMOVED! -repo.search.url: http://localhost:9200 +repo.search.url=http://localhost:9200 # Soon will be: #spring.elasticsearch.uris=http://localhost:9200 #spring.elasticsearch.username=user #spring.elasticsearch.password=secret #spring.elasticsearch.socket-timeout=10s - # Due to bug in spring cloud gateway # https://github.com/spring-cloud/spring-cloud-gateway/issues/3154 spring.cloud.gateway.proxy.sensitive=content-length - ################# ### Messaging ### ################# - # Enable (default)/disable messaging. The messaging functionality requires a RabbitMQ # server receiving and distributing the messages sent by this service. The server is # accessed via repo.messaging.hostname and repo.messaging.port @@ -140,48 +126,42 @@ repo.messaging.sender.exchange=record_events # E.g. if a resource has been created, the repository may has to perform additional # ingest steps. Therefore, special handlers can be added which will be executed at the # configured repo.schedule.rate if a new message has been received. -repo.schedule.rate:1000 - +repo.schedule.rate=1000 ####################################################### ##################### PIT Service ##################### ####################################################### # Standard resolver for Handle PIDs. Should usually stay like this. -pit.pidsystem.handle.baseURI = https://hdl.handle.net/ - +pit.pidsystem.handle.baseURI=https://hdl.handle.net/ ### Choosing and configuring the PID system ### # Available implementations: # - IN_MEMORY (default, sandboxed, non-permanent PIDs, for short testing / demonstration only), # - LOCAL (sandboxed, uses local database, no public PIDs!, for long term testing or special use-cases), # - HANDLE_PROTOCOL (recommended, for real FAIR Digital Objects), -pit.pidsystem.implementation = LOCAL +pit.pidsystem.implementation=LOCAL # If you chose IN_MEMORY, no further configuration is required. # If you chose LOCAL, no further configuration is required. # If you chose HANDLE_PROTOCOL, you need to set up your prefix and its key/certificate: -#pit.pidsystem.handle-protocol.credentials.handleIdentifierPrefix = 21.T11981 -#pit.pidsystem.handle-protocol.credentials.userHandle = 21.T11981/USER01 -#pit.pidsystem.handle-protocol.credentials.privateKeyPath = test_prefix_data/21.T11981_USER01_300_privkey.bin - +#pit.pidsystem.handle-protocol.credentials.handleIdentifierPrefix=21.T11981 +#pit.pidsystem.handle-protocol.credentials.userHandle=21.T11981/USER01 +#pit.pidsystem.handle-protocol.credentials.privateKeyPath=test_prefix_data/21.T11981_USER01_300_privkey.bin # The handle system supports the redirection of web browsers to a URL. # If your records may have such a URL stored in an attribute, you can # list the attributes here. The first attribute to be found will have # its value copied to a handle specific attribute (with key "URL"), # enabling URL redirection. Only affects the handle system! -# Obligation: Optional (option missing = empty list) -pit.pidsystem.handle-protocol.handleRedirectAttributes = {'21.T11148/b8457812905b83046284'} - +# Obligation: Optional (option missing=empty list) +pit.pidsystem.handle-protocol.handleRedirectAttributes={'21.T11148/b8457812905b83046284'} ### Base URL for the DTR used. ### # Currently, we support the DTRs of GWDG/ePIC. -pit.typeregistry.baseURI = https://typeapi.lab.pidconsortium.net +pit.typeregistry.baseURI=https://typeapi.lab.pidconsortium.net # If the attribute(s) keys/types in your PID records are not being # recognized as such, please contact us. # As a workaround, add them to this list. -# pit.validation.profileKeys = {} - +# pit.validation.profileKeys={} ### As this service is a RESTful serice without GUI, CSRF protection is not required. ### -pit.security.enable-csrf: false +pit.security.enable-csrf=false ### You may define patterns here for services which are allowed for communication. (CORS) ### -pit.security.allowedOriginPattern: http*://localhost:[*] - +pit.security.allowedOriginPattern=http*://localhost:[*] ### Caching settings for validation ### # The maximum number of entries in the cache. # pit.typeregistry.cache.maxEntries:1000 @@ -189,13 +169,11 @@ pit.security.allowedOriginPattern: http*://localhost:[*] # The time in minutes after which Entries will expire, starting from the # last update. # pit.typeregistry.cache.lifetimeMinutes:10 - # Profiles may disallow additional attributes in the PID records. This # option may be used to override this behavior for this instance. # If set to false, it will behave as the profiles describe. # If set to true, additional attributes will always be allowed. -pit.validation.alwaysAllowAdditionalAttributes = true - +pit.validation.alwaysAllowAdditionalAttributes=true ### DANGEROUS OPTIONS! Please read carefully! ######################################## # This will disable validation. It is only meant for testing and rare cases # where a DTR may not be available or an external validator is being @@ -203,34 +181,28 @@ pit.validation.alwaysAllowAdditionalAttributes = true # # pit.validation.strategy=none-debug ### DANGEROUS OPTIONS! Please read carefully! ######################################## - ####################################################### #################### PID GENERATOR #################### ####################################################### - # The PID generator to use for the suffix. Possible values: # "uuid4": generates a UUID v4 (random) PID suffix. # "hex-chunks": generates hex-chunks. Each chunk is four characters long. Example: 1D6C-152C-C9E0-C136-1509 -pit.pidgeneration.mode = uuid4 - +pit.pidgeneration.mode=uuid4 # A prefix for branding, in addition to the PID system prefix. # Structure: -# Example: branding-prefix = "my-project.", system-prefix = "21.T11981", suffix = "12345" -# => PID = "21.T11981/my-project.12345" +# Example: branding-prefix="my-project.", system-prefix="21.T11981", suffix="12345" +# => PID="21.T11981/my-project.12345" # -# pit.pidgeneration.branding-prefix = my-project. - +# pit.pidgeneration.branding-prefix=my-project. # Applies a casing on the PIDs after generation (see "mode" property). Possible values: # "lower": all characters are lower case # "upper": all characters are upper case # "unmodified": no casing is applied after generation. Result depends fully on the generator. -pit.pidgeneration.casing = lower - +pit.pidgeneration.casing=lower # Affects chunk-based generation modes (see pid.pidgeneration.mode) only. # Defines the number of chunks the generator should generate for each PID. # Default: 4 -# pit.pidgeneration.num-chunks = 4 - +# pit.pidgeneration.num-chunks=4 ### DANGEROUS OPTIONS! Please read carefully! ######################################## # Please keep this option as a last resort vor special use-cases # where you need total control about the PID suffix you want to create. @@ -238,12 +210,11 @@ pit.pidgeneration.casing = lower # a gateway which will manage your custom PIDs. # NOTE! If you do not already include the configured prefix in the PID, it will be appended. # This means that you can not create PIDs with a suffix starting with the system prefix. -# Example: system prefix = "abc", suffix = abcdef -# => PID = "abc/def" (delimiter may depend on PID system) +# Example: system prefix="abc", suffix=abcdef +# => PID="abc/def" (delimiter may depend on PID system) # -# pit.pidgeneration.custom-client-pids-enabled = false +# pit.pidgeneration.custom-client-pids-enabled=false ### DANGEROUS OPTIONS! Please read carefully! ######################################## - ################################ ######## Database ############## ################################ @@ -252,26 +223,24 @@ pit.pidgeneration.casing = lower ### system is set to LOCAL ### ### - Required for messaging ### ################################ - # This database will always run, as it is also required for the messaging feature, # but for the messaging it is not required to be persistent. # But the service will also use this database to store known PIDs. # This can be used as a backup or documentation of all PIDs. # The following properties can (and should) be set. - # When to store PIDs in the local database ("known PIDs") -pit.storage.strategy: keep-resolved-and-modified +pit.storage.strategy=keep-resolved-and-modified #pit.storage.strategy: keep-resolved # The driver determines the database system to start. Other drivers are untested, but may work. -spring.datasource.driver-class-name: org.h2.Driver +spring.datasource.driver-class-name=org.h2.Driver # Next, please choose a location for the database file on your file system. # WARNING: If no url is being defined, an in-memory database is being used, # loosing all data on restart. # WARNING: Change the DB to be stored somewhere outside of /tmp! -spring.datasource.url: jdbc:h2:file:/tmp/database;MODE=LEGACY;NON_KEYWORDS=VALUE +spring.datasource.url=jdbc:h2:file:/tmp/database;MODE=LEGACY;NON_KEYWORDS=VALUE # Credentials for the database: -spring.datasource.username: typid -spring.datasource.password: secure_me +spring.datasource.username=typid +spring.datasource.password=secure_me # Do not change ddl-auto if you do not know what you are doing: # https://docs.spring.io/spring-boot/docs/1.1.0.M1/reference/html/howto-database-initialization.html -spring.jpa.hibernate.ddl-auto: update +spring.jpa.hibernate.ddl-auto=update diff --git a/src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java b/src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java index 74dd0641..91c41be5 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java +++ b/src/main/java/edu/kit/datamanager/pit/web/BatchRecordResponse.java @@ -31,5 +31,7 @@ * @param mapping Map where keys are user-provided identifiers (fictionary) and values are the corresponding real record Handle PIDs. (Map) * @see PIDRecord */ -public record BatchRecordResponse(List pidRecords, Map mapping) { +public record BatchRecordResponse( + List pidRecords, + Map mapping) { } diff --git a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java index 72fe23af..af5a19f3 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java +++ b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024-2025 Karlsruhe Institute of Technology. + * Copyright (c) 2020-2025 Karlsruhe Institute of Technology. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import org.springdoc.core.converters.models.PageableAsQueryParam; import org.springframework.data.domain.Pageable; @@ -43,6 +44,10 @@ /** * @author jejkal */ +@RestController +@RequestMapping(value = "/api/v1/pit") +@Schema(description = "PID Information Types API") +@Tag(name = "PID Management", description = "PID Information Types API") public interface ITypingRestResource { /** @@ -82,7 +87,7 @@ public interface ITypingRestResource { responseCode = "201", description = "Successfully created all records and resolved references (if they exist). The response contains the created records and the mapping used to map from the user-provided, fictionary PIDs to the actual Handle PIDs created in the process.", content = { - @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, array = @ArraySchema(schema = @Schema(implementation = PIDRecord.class))) + @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = BatchRecordResponse.class)) }), @ApiResponse(responseCode = "400", description = "Validation failed. See body for details. Contains also the validated records.", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), @ApiResponse(responseCode = "406", description = "Provided input is invalid with regard to the supported accept header (Not acceptable)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index 64a6f635..b25910b6 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -36,19 +36,16 @@ import edu.kit.datamanager.service.IMessagingService; import edu.kit.datamanager.util.AuthenticationHelper; import edu.kit.datamanager.util.ControllerUtils; -import io.swagger.v3.oas.annotations.media.Schema; import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.lang3.stream.Streams; import org.apache.http.client.cache.HeaderConstants; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.WebRequest; @@ -62,34 +59,29 @@ import java.util.stream.Stream; @RestController -@RequestMapping(value = "/api/v1/pit") -@Schema(description = "PID Information Types API") public class TypingRESTResourceImpl implements ITypingRestResource { private static final Logger LOG = LoggerFactory.getLogger(TypingRESTResourceImpl.class); - @Autowired - protected ITypingService typingService; - @Autowired - protected Resolver resolver; - @Autowired - private ApplicationProperties applicationProps; - @Autowired - private IMessagingService messagingService; - @Autowired - private KnownPidsDao localPidStorage; + private final ITypingService typingService; + private final Resolver resolver; + private final ApplicationProperties applicationProps; + private final IMessagingService messagingService; + private final KnownPidsDao localPidStorage; + private final Optional elastic; + private final PidSuffixGenerator suffixGenerator; + private final PidGenerationProperties pidGenerationProperties; - @Autowired - private Optional elastic; - - @Autowired - private PidSuffixGenerator suffixGenerator; - - @Autowired - private PidGenerationProperties pidGenerationProperties; - - public TypingRESTResourceImpl() { + public TypingRESTResourceImpl(ITypingService typingService, Resolver resolver, ApplicationProperties applicationProps, IMessagingService messagingService, KnownPidsDao localPidStorage, Optional elastic, PidSuffixGenerator suffixGenerator, PidGenerationProperties pidGenerationProperties) { super(); + this.typingService = typingService; + this.resolver = resolver; + this.applicationProps = applicationProps; + this.messagingService = messagingService; + this.localPidStorage = localPidStorage; + this.elastic = elastic; + this.suffixGenerator = suffixGenerator; + this.pidGenerationProperties = pidGenerationProperties; } @Override From 1d481c41abe6e7e2c9245d9d186af5a1e30aea84 Mon Sep 17 00:00:00 2001 From: Maximilian Inckmann Date: Wed, 16 Jul 2025 20:54:26 +0200 Subject: [PATCH 107/108] minor style fixes Signed-off-by: Maximilian Inckmann --- build.gradle | 28 ++---- config/application-default.properties | 47 ++++++--- .../pit/web/ITypingRestResource.java | 97 +------------------ 3 files changed, 43 insertions(+), 129 deletions(-) diff --git a/build.gradle b/build.gradle index 659f134d..c59baade 100644 --- a/build.gradle +++ b/build.gradle @@ -105,22 +105,6 @@ application { mainClass = 'edu.kit.datamanager.pit.Application' } -/* - * Copyright (c) 2025 Karlsruhe Institute of Technology. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - // Makes executable jar file. // Available function available through Spring Boot Gradle Plugin. bootJar { @@ -205,12 +189,12 @@ jacocoTestReport { //exclude some classes/package from code coverage report classDirectories.setFrom(files(classDirectories.files.collect { fileTree(dir: it, exclude: [\ - 'edu/kit/datamanager/pit/configuration/**', \ - 'edu/kit/datamanager/pit/web/converter/**', \ - 'edu/kit/datamanager/pit/web/ExtendedErrorAttributes**', \ - 'edu/kit/datamanager/pit/web/UncontrolledExceptionHandler**', \ - 'edu/kit/datamanager/pit/common/**', \ - 'edu/kit/datamanager/pit/Application*' + 'edu/kit/datamanager/pit/configuration/**', \ + 'edu/kit/datamanager/pit/web/converter/**', \ + 'edu/kit/datamanager/pit/web/ExtendedErrorAttributes**', \ + 'edu/kit/datamanager/pit/web/UncontrolledExceptionHandler**', \ + 'edu/kit/datamanager/pit/common/**', \ + 'edu/kit/datamanager/pit/Application*' ]) })) } diff --git a/config/application-default.properties b/config/application-default.properties index 4e6f72a4..cea964f9 100644 --- a/config/application-default.properties +++ b/config/application-default.properties @@ -1,18 +1,17 @@ +# External configuration file for Typed PID Maker +# ----------------------------------------------- +# Regarding the location of this file, consider the default paths for Spring Boot configurations, documented here: +# https://docs.spring.io/spring-boot/reference/features/external-config.html#:~:text=config%20data%20files%20are%20considered%20in%20the%20following%20order%3A +# You can change the path with e.g. this command (if you use gradle to run it): +# ./gradlew run --args="--spring.config.location=config/application-default.properties" +# Or by passing the parameter directly to the jar file. # -# Copyright (c) 2025 Karlsruhe Institute of Technology. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# +# Documentation of common Spring Boot configuration properties (logging, ports, and others): +# https://docs.spring.io/spring-boot/appendix/application-properties/index.html +# More specific properties are documented within this file. + +### General Spring Boot Settings ### +# When to include the "message" attribute in HTTP responses on uncatched exceptions. server.error.include-message=always springdoc.api-docs.enabled=true springdoc.swagger-ui.enabled=true @@ -21,6 +20,8 @@ springdoc.show-actuator=true spring.main.allow-bean-definition-overriding=true spring.data.rest.detection-strategy=annotated ##################################################### + + ########################### ### Port, SSL, Security ### ########################### @@ -44,6 +45,8 @@ management.endpoint.health.access=unrestricted management.endpoint.health.show-details=ALWAYS management.endpoint.health.sensitive=false management.endpoints.web.exposure.include=* + + ############### ### Logging ### ############### @@ -59,6 +62,8 @@ logging.level.org.springframework=WARN logging.level.org.springframework.amqp=WARN #logging.level.com.zaxxer.hikari: ERROR logging.level.edu.kit.datamanager.pit.cli=INFO + + ###################### ### Authentication ### ###################### @@ -76,6 +81,8 @@ repo.auth.enabled=false # be able to validate the signature. By default, the secret should be selected randomly # and with a sufficient length. repo.auth.jwtSecret=vkfvoswsohwrxgjaxipuiyyjgubggzdaqrcuupbugxtnalhiegkppdgjgwxsmvdb + + ############################### ### Keycloak Authentication ### ############################### @@ -89,6 +96,8 @@ spring.autoconfigure.exclude=org.keycloak.adapters.springboot.KeycloakAutoConfig #keycloak.realm=myrealm #keycloak.auth-server-url=http://localhost:8080/auth #keycloak.resource=keycloak-angular + + ############################################ ### Elastic Indexing and search endpoint ### ######## (requires Elasticsearch 8) ######## @@ -107,6 +116,8 @@ repo.search.url=http://localhost:9200 # Due to bug in spring cloud gateway # https://github.com/spring-cloud/spring-cloud-gateway/issues/3154 spring.cloud.gateway.proxy.sensitive=content-length + + ################# ### Messaging ### ################# @@ -127,6 +138,8 @@ repo.messaging.sender.exchange=record_events # ingest steps. Therefore, special handlers can be added which will be executed at the # configured repo.schedule.rate if a new message has been received. repo.schedule.rate=1000 + + ####################################################### ##################### PIT Service ##################### ####################################################### @@ -174,6 +187,7 @@ pit.security.allowedOriginPattern=http*://localhost:[*] # If set to false, it will behave as the profiles describe. # If set to true, additional attributes will always be allowed. pit.validation.alwaysAllowAdditionalAttributes=true + ### DANGEROUS OPTIONS! Please read carefully! ######################################## # This will disable validation. It is only meant for testing and rare cases # where a DTR may not be available or an external validator is being @@ -181,6 +195,8 @@ pit.validation.alwaysAllowAdditionalAttributes=true # # pit.validation.strategy=none-debug ### DANGEROUS OPTIONS! Please read carefully! ######################################## + + ####################################################### #################### PID GENERATOR #################### ####################################################### @@ -203,6 +219,7 @@ pit.pidgeneration.casing=lower # Defines the number of chunks the generator should generate for each PID. # Default: 4 # pit.pidgeneration.num-chunks=4 + ### DANGEROUS OPTIONS! Please read carefully! ######################################## # Please keep this option as a last resort vor special use-cases # where you need total control about the PID suffix you want to create. @@ -215,6 +232,8 @@ pit.pidgeneration.casing=lower # # pit.pidgeneration.custom-client-pids-enabled=false ### DANGEROUS OPTIONS! Please read carefully! ######################################## + + ################################ ######## Database ############## ################################ diff --git a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java index af5a19f3..b0a1985c 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java +++ b/src/main/java/edu/kit/datamanager/pit/web/ITypingRestResource.java @@ -50,22 +50,6 @@ @Tag(name = "PID Management", description = "PID Information Types API") public interface ITypingRestResource { - /** - * Create multiple, possibly related PID records using the record information. - * This endpoint is a convenience method to create multiple PID records at once. - * For connecting records, the PID fields must be specified and the value may be used in the value fields of other PIDRecordEntries. - * The provided PIDs will be overwritten as defined by the PID generator strategy. - *

- * Note: This endpoint does not support custom PIDs, as the PID field is used for "imaginary" PIDs to connect records. - * These "imaginary" PIDs will be overwritten with actual, resolvable PIDs as defined by the PID generator strategy. - * If you want to create a record with custom PIDs, use the endpoint `POST /pid`. - * - * @param rec A list of PID records. - * @param dryrun If true, only validation will be done and no PIDs will be created. No data will be changed and no services will be notified. - * @return either 201 and a list of record representations, or an error (see ApiResponse annotations and tests). - * @throws IOException if an error occurs. - * @throws edu.kit.datamanager.pit.common.RecordValidationException if any of the records is invalid, or a PID was used for multiple records in the same request. - */ @PostMapping( path = "pids", consumes = {MediaType.APPLICATION_JSON_VALUE}, @@ -73,10 +57,11 @@ public interface ITypingRestResource { ) @Operation( summary = "Create a multiple, possibly related PID records", - description = "Create multiple, possibly related PID records using the record information from the request body. To connect records, the PID fields must be specified. This 'imaginary' PID value may then be used in the value fields of other PID Record entries. During creation, these `imaginary` PIDs whose sole purpose is to connect records will be overwritten with actual, resolvable PIDs as defined by the PID generator strategy. Note: This procedure does not support custom PIDs, as the PID field is used for linking records. If you want to create a record with custom PIDs, use the endpoint `POST /pid`." + description = "Create multiple, possibly related PID records using the record information. This endpoint is a convenience method to create multiple PID records at once. For connecting records, the PID fields must be specified and the value may be used in the value fields of other PIDRecordEntries. The provided PIDs will be overwritten as defined by the PID generator strategy.\n" + + "Note: This endpoint does not support custom PIDs, as the PID field is used for \"placeholder\" PIDs to connect records. These placeholder PIDs will be replaced by actual, resolvable PIDs as defined by the PID generator strategy. This goes for the PID referencing a record as well as references from other records, if they are provided as a single attribute value (i.e., not a JSON array within an attribute's value). If you want to create a record with custom PID suffixes, use the endpoint `POST /pid` and configure the Typed PID Maker accordingly." ) @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "The body containing a list of all PID record values as they should be in the new PID records. To connect records, the PID fields must be specified. This 'imaginary' PID value may then be used in the value fields of other PID Record entries. During creation, these `imaginary` PIDs whose sole purpose is to connect records will be overwritten with actual, resolvable PIDs as defined by the PID generator strategy.", + description = "The body containing a list of all PID record values as they should be in the new PID records. To connect records, the PID fields must be specified. This placeholder PID value may then be used in the value fields of other PID Record entries. During creation, these placeholder PIDs whose sole purpose is to connect records will be overwritten with actual, resolvable PIDs as defined by the PID generator strategy.", required = true, content = { @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, array = @ArraySchema(schema = @Schema(implementation = PIDRecord.class))) @@ -85,7 +70,7 @@ public interface ITypingRestResource { @ApiResponses(value = { @ApiResponse( responseCode = "201", - description = "Successfully created all records and resolved references (if they exist). The response contains the created records and the mapping used to map from the user-provided, fictionary PIDs to the actual Handle PIDs created in the process.", + description = "Successfully created all records and resolved references (if they exist). The response contains the created records and the mapping used to map from the user-provided, placeholder PIDs to the actual Handle PIDs created in the process.", content = { @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = BatchRecordResponse.class)) }), @@ -108,24 +93,6 @@ ResponseEntity createPIDs( final UriComponentsBuilder uriBuilder ) throws IOException; - /** - * Create a new PID using the record information provided in the request body. - * The record is expected to contain the identifier of the matching profile. - * Before creating the record, the record information will be validated against - * the profile. - *

- * Important note: Validation caches recently used type information locally. - * Therefore, changes in a registry may take a few minutes to be reflected - * within the Typed PID Maker. This speeds up validation drastically in most - * situations. But it also means that, if the cache is empty, validation may - * take 30+ seconds. We are aware of the issue and considering improvements. But - * be aware that in general, validation may take up some time. - * - * @param rec The PID record. - * @return either 201 and a record representation, or an error (see ApiResponse - * annotations and tests). - * @throws IOException if an error occurs. - */ @PostMapping( path = "pid/", consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, @@ -182,18 +149,6 @@ ResponseEntity createPID( final UriComponentsBuilder uriBuilder ) throws IOException; - /** - * Update the given PIDs record using the information provided in the request - * body. The record is expected to contain the identifier of the matching - * profile. Conditions for a valid record are the same as for creation. - *

- * Important note: Validation may take up to 30+ seconds. For details, see the - * documentation of "POST /pid/". - * - * @param rec the PID record. - * @return the record (on success). - * @throws IOException if an error occurs. - */ @PutMapping( path = "pid/**", consumes = {MediaType.APPLICATION_JSON_VALUE, SimplePidRecord.CONTENT_TYPE}, @@ -286,19 +241,6 @@ ResponseEntity getRecord( final UriComponentsBuilder uriBuilder ) throws IOException; - /** - * Requests a PID from the local store. If this PID is known, it will be - * returned together with the timestamps of creation and modification executed - * on this PID by this service. - *

- * This store is not a cache! - * Instead, the service remembers every PID that it - * created (and resolved, depending on the configuration parameter - * `pit.storage.strategy` of the service) on request. - * - * @return the known PID and its timestamps. - * @throws IOException if an error occurs. - */ @Operation( summary = "Returns a PID and its timestamps from the local store, if available.", description = "Returns a PID from the local store. This store is not a cache! Instead, the" @@ -327,24 +269,6 @@ ResponseEntity findByPid( final UriComponentsBuilder uriBuilder ) throws IOException; - /** - * Returns all known PIDs, limited by the given page size and number. - * Several filtering criteria are also available. - *

- * Known PIDs are defined as being stored in a local store. This store is not a - * cache! Instead, the service remembers every PID that it created (and - * resolved, depending on the configuration parameter `pit.storage.strategy` of - * the service) on request. - * - * @param createdAfter defines the earliest date for the creation timestamp. - * @param createdBefore defines the latest date for the creation timestamp. - * @param modifiedAfter defines the earliest date for the modification - * timestamp. - * @param modifiedBefore defines the latest date for the modification timestamp. - * @param pageable defines page size and page to navigate through large - * lists. - * @return the PIDs matching all given constraints. - */ @Operation( summary = "Returns all known PIDs. Supports paging, filtering criteria, and different formats.", description = "Returns all known PIDs, limited by the given page size and number. " @@ -392,19 +316,6 @@ ResponseEntity> findAll( UriComponentsBuilder uriBuilder ) throws IOException; - /** - * Like findAll, but the return value is formatted for the tabulator - * JavaScript library. - * - * @param createdAfter defines the earliest date for the creation timestamp. - * @param createdBefore defines the latest date for the creation timestamp. - * @param modifiedAfter defines the earliest date for the modification - * timestamp. - * @param modifiedBefore defines the latest date for the modification timestamp. - * @param pageable defines page size and page to navigate through large - * lists. - * @return the PIDs matching all given constraints. - */ @Operation( summary = "Returns all known PIDs. Supports paging, filtering criteria, and different formats.", description = "Returns all known PIDs, limited by the given page size and number. " From 269776c6c174209e5063531c390895e1bdc9063b Mon Sep 17 00:00:00 2001 From: Andreas Pfeil Date: Tue, 30 Sep 2025 11:55:33 +0200 Subject: [PATCH 108/108] fix: API for creating multiple PIDs returns mappings now with full PIDs, including the prefix. Fixes #325 Before, we returned a map "placeholder -> suffix". Now we return properly "placeholder -> full_PID" --- .../pit/web/impl/TypingRESTResourceImpl.java | 7 +++++++ .../datamanager/pit/web/ConnectedPIDsTest.java | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java index b25910b6..88a99f94 100644 --- a/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java +++ b/src/main/java/edu/kit/datamanager/pit/web/impl/TypingRESTResourceImpl.java @@ -122,6 +122,7 @@ public ResponseEntity createPIDs( LOG.info("-- Time taken for mapping: {} ms", ChronoUnit.MILLIS.between(startTime, mappingTime)); LOG.info("-- Time taken for validation: {} ms", ChronoUnit.MILLIS.between(mappingTime, validationTime)); LOG.info("Dryrun finished. Returning validated records for {} records.", validatedRecords.size()); + addPrefixToMapping(pidMappings, prefix); return ResponseEntity.status(HttpStatus.OK).body(new BatchRecordResponse(validatedRecords, pidMappings)); } @@ -186,13 +187,19 @@ public ResponseEntity createPIDs( } LOG.info("Creation finished. Returning validated records for {} records. {} records failed to be created.", validatedRecords.size(), failedRecords.size()); + addPrefixToMapping(pidMappings, prefix); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new BatchRecordResponse(failedRecords, pidMappings)); } else { LOG.info("Creation finished. Returning successfully validated and created records for {} records of {}.", successfulRecords.size(), validatedRecords.size()); + addPrefixToMapping(pidMappings, prefix); return ResponseEntity.status(HttpStatus.CREATED).body(new BatchRecordResponse(successfulRecords, pidMappings)); } } + private static void addPrefixToMapping(Map pidMappings, String prefix) { + pidMappings.replaceAll((placeholder, realSuffix) -> prefix + realSuffix); + } + /** * This method generates a mapping between user-provided "fantasy" PIDs and real PIDs. * diff --git a/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java b/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java index 7063e900..ea729b98 100644 --- a/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java +++ b/src/test/java/edu/kit/datamanager/pit/web/ConnectedPIDsTest.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import edu.kit.datamanager.pit.domain.PIDRecord; import edu.kit.datamanager.pit.pidlog.KnownPidsDao; +import edu.kit.datamanager.pit.pitservice.ITypingService; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -34,7 +35,9 @@ import org.springframework.test.web.servlet.result.MockMvcResultHandlers; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import org.springframework.web.context.WebApplicationContext; +import org.hamcrest.Matchers; +import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -64,6 +67,8 @@ class ConnectedPIDsTest { private ObjectMapper mapper; @Autowired private KnownPidsDao knownPidsDao; + @Autowired + private ITypingService typingService; @BeforeEach void setup() { @@ -341,6 +346,9 @@ void checkValidConnectedRecords() throws Exception { // Submit to API String jsonContent = mapper.writeValueAsString(records); + String prefix = this.typingService.getPrefix() + .orElseThrow(() -> new IOException("No prefix configured.")); + this.mockMvc .perform(post("/api/v1/pit/pids") .contentType(MediaType.APPLICATION_JSON) @@ -351,7 +359,12 @@ void checkValidConnectedRecords() throws Exception { .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords").isArray()) .andExpect(MockMvcResultMatchers.jsonPath("$.pidRecords.length()").value(records.size())) .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").exists()) - .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").isMap()); + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping").isMap()) + // The mapping values must contain a full PID, not only the suffix, + // to prevent client-side errors with partial PIDs. + .andExpect(MockMvcResultMatchers.jsonPath("$.mapping") + .value(Matchers.hasValue(Matchers.startsWith(prefix))) + ); assertEquals(records.size(), knownPidsDao.count(), "Number of stored records should match the number of records submitted"); }