diff --git a/src/main/java/edu/harvard/iq/dataverse/AbstractGlobalIdServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/AbstractGlobalIdServiceBean.java index 479438f3f45..0875bab1315 100644 --- a/src/main/java/edu/harvard/iq/dataverse/AbstractGlobalIdServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/AbstractGlobalIdServiceBean.java @@ -1,5 +1,7 @@ package edu.harvard.iq.dataverse; +import edu.harvard.iq.dataverse.pidproviders.VersionPidMode.GenStyle; +import edu.harvard.iq.dataverse.settings.JvmSettings; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; import edu.harvard.iq.dataverse.util.SystemConfig; import java.io.InputStream; @@ -155,6 +157,50 @@ public DvObject generateIdentifier(DvObject dvObject) { return dvObject; } + /** + * Generate an identifier for a given dataset version, depending on the chosen (configured) generation style. + * (See also {@link GenStyle} for available styles.) + * + * @param datasetVersion The version of a dataset to create a PID for + * @return The identifier (will never be null) + * @throws IllegalArgumentException If the style configured is not supported by this generator, the version is + * already released or a minor version, or if the owning dataset has no identifier + * while creating a suffix style identifier. + */ + @Override + public String generateDatasetVersionIdentifier(final DatasetVersion datasetVersion) throws IllegalArgumentException { + if (datasetVersion == null || datasetVersion.isReleased()) { + throw new IllegalArgumentException("Version may not be null or released"); + } + + // If this is a minor version update, reuse the identifier of the last released version + if (datasetVersion.getMinorVersionNumber() > 0) { + return datasetVersion.getDataset().getReleasedVersion().getPersistentIdentifier(); + } + + try { + GenStyle style = JvmSettings.PID_VERSIONS_STYLE.lookup(GenStyle.class); + + if (style == GenStyle.DATASET) { + return generateDatasetIdentifier(datasetVersion.getDataset()); + + } else if (style == GenStyle.SUFFIX) { + String datasetIdentifier = datasetVersion.getDataset().getIdentifier(); + if (datasetIdentifier == null || datasetIdentifier.isEmpty()) { + throw new IllegalArgumentException("Dataset must not have empty identifier when creating dataset version identifier by suffix"); + } + + return datasetIdentifier + getVersionSuffixDelimiter() + datasetVersion.getVersionNumber(); + + // Nothing appropriate found - bail out + } else { + throw new IllegalArgumentException("No supported version PID generation style configured"); + } + } catch (NoSuchElementException e) { + throw new IllegalArgumentException("No supported version PID generation style configured", e); + } + } + //ToDo just send the DvObject.DType public String generateDatasetIdentifier(Dataset dataset) { //ToDo - track these in the bean diff --git a/src/main/java/edu/harvard/iq/dataverse/DOIDataCiteServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DOIDataCiteServiceBean.java index c5d4faa0569..dc75b95374f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DOIDataCiteServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/DOIDataCiteServiceBean.java @@ -63,7 +63,7 @@ public boolean alreadyExists(GlobalId pid) { @Override - public String createIdentifier(DvObject dvObject) throws Exception { + public String createIdentifier(DvObject dvObject) throws IOException { logger.log(Level.FINE,"createIdentifier"); if(dvObject.getIdentifier() == null || dvObject.getIdentifier().isEmpty() ){ dvObject = generateIdentifier(dvObject); diff --git a/src/main/java/edu/harvard/iq/dataverse/DOIEZIdServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DOIEZIdServiceBean.java index 5776aca8c8a..914b8af816d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DOIEZIdServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/DOIEZIdServiceBean.java @@ -3,6 +3,8 @@ import edu.harvard.iq.dataverse.settings.JvmSettings; import edu.ucsb.nceas.ezid.EZIDException; import edu.ucsb.nceas.ezid.EZIDService; + +import java.io.IOException; import java.util.*; import java.util.logging.Level; import java.util.logging.Logger; @@ -240,7 +242,7 @@ public List getProviderInformation(){ } @Override - public String createIdentifier(DvObject dvObject) throws Throwable { + public String createIdentifier(DvObject dvObject) throws IOException { logger.log(Level.FINE, "createIdentifier"); if(dvObject.getIdentifier() == null || dvObject.getIdentifier().isEmpty() ){ dvObject = generateIdentifier(dvObject); @@ -265,7 +267,7 @@ public String createIdentifier(DvObject dvObject) throws Throwable { logger.log(Level.WARNING, "cause", e.getCause()); logger.log(Level.WARNING, "message {0}", e.getMessage()); logger.log(Level.WARNING, "identifier: ", identifier); - throw e; + throw new IOException(e); } } diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetVersion.java b/src/main/java/edu/harvard/iq/dataverse/DatasetVersion.java index 2873d2f2a22..fde0168d916 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DatasetVersion.java +++ b/src/main/java/edu/harvard/iq/dataverse/DatasetVersion.java @@ -129,6 +129,33 @@ public enum VersionState { @Column(length = VERSION_NOTE_MAX_LENGTH) private String versionNote; + /** + * A (globally) unique persistent identifier for this version. + * The version PID will always be dependent on the protocol and authority of the containing dataset. + * This identifier may contain {@link edu.harvard.iq.dataverse.settings.SettingsServiceBean.Key#Shoulder} if + * configured and some more unique characters, also depending on the admin's choice to make version PIDs dependent + * on the dataset PID. + * + * The PID may be null (feature disabled, old entry, etc.). It might not be unique, as minor versions by default + * carry the identifier of their adjacent major version. + */ + @Column + private String persistentIdentifier; + + /** + * Caching the {@link GlobalId} in a transient field saves retrievals and reformatting. Its value will + * be based on {@link #persistentIdentifier}, and details from {@link #dataset} like protocol, authority and + * shoulder. + */ + @Transient + private GlobalId globalId; + + /** + * Saving in the database if this identifier has been registered before to avoid + * re-registration (which would probably fail) and switch to modification + */ + private boolean identifierRegistered = false; + /* * @todo versionState should never be null so when we are ready, uncomment * the `nullable = false` below. @@ -233,6 +260,56 @@ public Long getVersion() { public void setVersion(Long version) { } + + public String getPersistentIdentifier() { + return this.persistentIdentifier; + } + + public void setPersistentIdentifier(String identifier) { + this.persistentIdentifier = identifier; + } + + /** + * Create a {@link GlobalId} from {@link #persistentIdentifier} and the owning {@link #dataset} + * details of protocol and authority. This method is not free of side effects: it will cache + * the generated value in a transient instance variable if not yet initialized. + * + * @return The global id for this version or null if no PID, protocol or authority present. + */ + public GlobalId getGlobalId() { + if (this.globalId == null && this.getPersistentIdentifier() != null && + this.dataset.getProtocol() != null && this.dataset.getAuthority() != null) { + this.globalId = PidUtil.parseAsGlobalID( + this.dataset.getProtocol(), + this.dataset.getAuthority(), + this.getPersistentIdentifier()); + } + return this.globalId; + } + + /** + * Check the status of the version identifier - has it been registered? + * @return True if registered, false otherwise. + */ + public boolean isIdentifierRegistered() { + return this.identifierRegistered; + } + + /** + * Set registration as done. + */ + public void setIdentifierRegistered() { + this.identifierRegistered = true; + } + + /** + * Overwrite the registration status with a specific value + * @param status The new status of the registration + */ + public void setIdentifierRegistered(boolean status) { + this.identifierRegistered = status; + } + public String getDataverseSiteUrl() { return dataverseSiteUrl; diff --git a/src/main/java/edu/harvard/iq/dataverse/Dataverse.java b/src/main/java/edu/harvard/iq/dataverse/Dataverse.java index bc8716b6129..25aab8ba2db 100644 --- a/src/main/java/edu/harvard/iq/dataverse/Dataverse.java +++ b/src/main/java/edu/harvard/iq/dataverse/Dataverse.java @@ -2,7 +2,7 @@ import edu.harvard.iq.dataverse.harvest.client.HarvestingClient; import edu.harvard.iq.dataverse.authorization.DataverseRole; -import edu.harvard.iq.dataverse.dataaccess.DataAccess; +import edu.harvard.iq.dataverse.pidproviders.VersionPidMode.CollectionConduct; import edu.harvard.iq.dataverse.search.savedsearch.SavedSearch; import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.util.SystemConfig; @@ -30,14 +30,13 @@ import javax.persistence.OneToOne; import javax.persistence.OrderBy; import javax.persistence.Table; -import javax.persistence.Transient; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; import javax.validation.constraints.Size; import org.apache.commons.lang3.StringUtils; -import org.hibernate.validator.constraints.NotBlank; -import org.hibernate.validator.constraints.NotEmpty; /** * @@ -182,8 +181,7 @@ public void setDefaultContributorRole(DataverseRole defaultContributorRole) { private boolean facetRoot; // By default, themeRoot should be true, as new dataverses should start with the default theme private boolean themeRoot = true; - private boolean templateRoot; - + private boolean templateRoot; @OneToOne(mappedBy = "dataverse",cascade={ CascadeType.REMOVE, CascadeType.MERGE,CascadeType.PERSIST}, orphanRemoval=true) private DataverseTheme dataverseTheme; @@ -591,6 +589,31 @@ public void setCitationDatasetFieldTypes(List citationDatasetF } + + /** + * Indicate if this Dataverse Collection wants to publicize PIDs for each (major) {@link DatasetVersion} + * for any {@link Dataset} in it. + * + * @see edu.harvard.iq.dataverse.pidproviders.VersionPidMode#ALLOW_MAJOR + * @see edu.harvard.iq.dataverse.pidproviders.VersionPidMode#ALLOW_MINOR + * @see CollectionConduct + */ + @Enumerated(EnumType.STRING) + private CollectionConduct datasetVersionPidConduct; + + public void setDatasetVersionPidConduct(CollectionConduct conduct) { + this.datasetVersionPidConduct = conduct; + } + + /** + * Retrieve the version PID conduct mode for this collection + * @return One of {@link CollectionConduct}. Never null, defaults to {@link CollectionConduct#INHERIT}. + */ + public CollectionConduct getDatasetVersionPidConduct() { + return this.datasetVersionPidConduct != null ? this.datasetVersionPidConduct : CollectionConduct.INHERIT; + } + + public List getDataverseFacets() { return getDataverseFacets(false); diff --git a/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java index e092f209acd..7fd53cf6a0d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataverseServiceBean.java @@ -15,9 +15,11 @@ import edu.harvard.iq.dataverse.batch.util.LoggingUtil; import edu.harvard.iq.dataverse.dataaccess.ImageThumbConverter; import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.pidproviders.VersionPidMode; import edu.harvard.iq.dataverse.search.IndexServiceBean; import edu.harvard.iq.dataverse.search.SolrIndexServiceBean; import edu.harvard.iq.dataverse.search.SolrSearchResult; +import edu.harvard.iq.dataverse.settings.JvmSettings; import edu.harvard.iq.dataverse.util.StringUtil; import edu.harvard.iq.dataverse.util.SystemConfig; import java.io.File; @@ -28,9 +30,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.logging.Level; import java.util.logging.Logger; import java.util.Properties; -import java.util.concurrent.Future; import javax.ejb.EJB; import javax.ejb.Stateless; import javax.inject.Inject; @@ -927,6 +930,57 @@ public List getDatasetTitlesWithinDataverse(Long dataverseId) { return em.createNativeQuery(cqString).getResultList(); } - + /** + * Check if a given Dataverse Collection has been configured to generate PIDs for a new version of a dataset + * contained in it. Will also respect the global version PID settings by an admin via + * {@link JvmSettings#PID_VERSIONS_MODE}. + * + * @param collection The collection to check. May not be null (will throw NPE). + * @param willBeMinorVersion Will the {@link DatasetVersion} to receive the PID be a minor version? + * + * @return true if enabled, false if disabled + * @throws java.util.NoSuchElementException When no or invalid configuration for version PID mode is given + */ + public boolean wantsDatasetVersionPids(final Dataverse collection, boolean willBeMinorVersion) { + Objects.requireNonNull(collection, "Collection parameter must not be null"); + + // Deactivated by admin globally or no PID for minor version allowed? + VersionPidMode vpm = JvmSettings.PID_VERSIONS_MODE.lookup(VersionPidMode.class); + if (VersionPidMode.OFF.equals(vpm) || ( willBeMinorVersion && VersionPidMode.ALLOW_MAJOR.equals(vpm) )) { + return false; + } + + // Check the collection itself; and potentially it's ancestors + Dataverse c = collection; + while (c != null) { + // Note: the default behavior is INHERIT for the model class + switch (c.getDatasetVersionPidConduct()) { + case SKIP: + logger.log(Level.FINE, "Collection {0} makes {1} skip version PIDs", new String[]{c.getAlias(), collection.getAlias()}); + return false; + case MAJOR: + logger.log(Level.FINE, "Collection {0} allows its sub {1} PIDs for major versions", new String[]{c.getAlias(), collection.getAlias()}); + return !willBeMinorVersion; + case MINOR: + if (vpm.equals(VersionPidMode.ALLOW_MINOR)) { + logger.log(Level.FINE, "Collection {0} allows its sub {1} PIDs for minor versions", new String[]{c.getAlias(), collection.getAlias()}); + return true; + } else { + // In some cases, an admin might have switched the setting after someone already activated it. + // The collection's conduct mode should be updated - we will still cap it as admin says no. + logger.log(Level.INFO, "Collection {0} allows its sub {1} PIDs for minor versions, which is disabled globally. Please update conduct mode of {0}.", new String[]{c.getAlias(), collection.getAlias()}); + return !willBeMinorVersion; + } + case INHERIT: + // Note: root dataverse has no owner, which will break the loop condition + c = c.getOwner(); + } + } + + // If the root dataverse did also not have a policy set, use what the admin configured. + // Note: one could argue we should just return true here, as the below boolean expression is just the + // negation of the one at the top and the collections didn't intervene. But better safe than sorry... + return VersionPidMode.ALLOW_MINOR.equals(vpm) || (!willBeMinorVersion && VersionPidMode.ALLOW_MAJOR.equals(vpm)); + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/GlobalIdServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/GlobalIdServiceBean.java index 4ff3d6dc9ac..89b537e17f4 100644 --- a/src/main/java/edu/harvard/iq/dataverse/GlobalIdServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/GlobalIdServiceBean.java @@ -2,10 +2,12 @@ import static edu.harvard.iq.dataverse.GlobalIdServiceBean.logger; import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.exception.NotImplementedException; import edu.harvard.iq.dataverse.pidproviders.PermaLinkPidProviderServiceBean; import edu.harvard.iq.dataverse.pidproviders.PidUtil; import edu.harvard.iq.dataverse.settings.SettingsServiceBean.Key; +import java.io.IOException; import java.util.*; import java.util.function.Function; import java.util.logging.Level; @@ -24,9 +26,36 @@ public interface GlobalIdServiceBean { boolean isConfigured(); List getProviderInformation(); - - String createIdentifier(DvObject dvo) throws Throwable; - + + /** + * Let the PID provider create the identifier for a dataset or datafile (collections are not yet supported) by + * linking it to the target URL. This might involve registering metadata of the object alongside. + * The identifier is not yet to be published - this step is done by {@link #publicizeIdentifier(DvObject)}. + * + * @param dvo The object to create the identifier for + * @return Some response or nothing at all (null). Up to the provider. + * @throws IOException If creation fails. May contain wrapped root causes. + */ + String createIdentifier(DvObject dvo) throws IOException; + + /** + * Let the PID provider create the identifier for a dataset version like it does in {@link #createIdentifier(DvObject)}. + * The identifier is not yet to be published - this step is done by {@link #publicizeIdentifier(DatasetVersion)}. + * Note that the provider needs to decide if minor versions get their own identifiers as well. If minor versions + * are to be updates of majors, this may be done in {@link #publicizeIdentifier(DatasetVersion)} as well. + * + * @implNote This method is expected to create a new identifier, not overwrite an existing one, and not make + * an identifier findable yet (this is the job of {@link #publicizeIdentifier(DatasetVersion)}). + * + * @param datasetVersion The version to be registered at the provider + * @return Some response or nothing at all (null). Up to the provider. + * @throws IOException If creation fails. May contain wrapped root causes. + * @throws NotImplementedException If version registration is not supported by the provider + */ + default String createIdentifier(DatasetVersion datasetVersion) throws IOException { + throw new NotImplementedException("This provider does not (yet) support creating identifiers for versions"); + } + Map getIdentifierMetadata(DvObject dvo); String modifyIdentifierTargetURL(DvObject dvo) throws Exception; @@ -43,8 +72,60 @@ public interface GlobalIdServiceBean { boolean publicizeIdentifier(DvObject studyIn); + /** + * Publish a PID for a given {@link DatasetVersion} and make it findable and resolvable. + * + * @apiNote This method is meant to be called when a new version identifier is about to be published which is + * already created - either by calling {@link #createIdentifier(DatasetVersion)} before or knowing this + * will be an update of an existing one. + * + * @param datasetVersion The version to publish + * @return true if successful, false otherwise (or when datasetVersion is null) + * @throws IOException In case the communication with the provider failed for some reason. + * @throws NotImplementedException When a provider does not support PIDs for versions + */ + default boolean publicizeIdentifier(final DatasetVersion datasetVersion) throws IOException { + throw new NotImplementedException("This provider does not (yet) support publishing versions."); + } + String generateDatasetIdentifier(Dataset dataset); String generateDataFileIdentifier(DataFile datafile); + + /** + * Generate a PID for a {@link DatasetVersion}. + * Note that the generation of this identifier depends on configuration by a sysadmin and concrete + * implementation for a given PID provider (it might be limited by its capabilities). + * The provider may return an existing PID of a former version, e.g. to reuse an existing identifier + * in case of minor version updates. + * + * @implNote This method is meant to be implemented free of side effects (not manipulating the version). + * Take care not to throw other exception than those documented here to avoid EJB exception handling + * kicking in. + * + * @param datasetVersion The version of a dataset to create a PID for + * @return An "identifier", meant to be used for {@link GlobalId}, retrievable via {@link GlobalId#getIdentifier()}. + * Must not be null. + * @throws NotImplementedException When a provider does not support generating PIDs for version. + * @throws IllegalArgumentException When a provider does not allow their generation due to configuration or doesn't + * like the current look of the version (i.e. not being a new major version). + * Note: this is made a checked exception to make it a business exception, + * avoiding EJB exception handling and handling inside command engine. + */ + default String generateDatasetVersionIdentifier(final DatasetVersion datasetVersion) throws IllegalArgumentException { + throw new NotImplementedException("This provider does not (yet) support publishing versions."); + } + + /** + * Retrieve the character that is inserted as a delimiter between the dataset identifier + * and the version number. Defaults to a dot ".", but in case a PID system does not support + * this character, the provider can override it. + * + * @return The delimiter character, defaulting to '.' + */ + default char getVersionSuffixDelimiter() { + return '.'; + } + boolean isGlobalIdUnique(GlobalId globalId); String getUrlPrefix(); diff --git a/src/main/java/edu/harvard/iq/dataverse/HandlenetServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/HandlenetServiceBean.java index 9ac4d5e29ae..e85408fd350 100644 --- a/src/main/java/edu/harvard/iq/dataverse/HandlenetServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/HandlenetServiceBean.java @@ -25,6 +25,7 @@ import java.io.File; import java.io.FileInputStream; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.logging.Level; @@ -140,11 +141,17 @@ public void reRegisterHandle(DvObject dvObject) { } else { // Create a new handle from scratch: logger.log(Level.INFO, "Handle {0} not registered. Registering (creating) from scratch.", handle); - registerNewHandle(dvObject); + try { + registerNewHandle(dvObject); + // HINT: This is a hack. Before switch to throwing exceptions, the method above would have returned an + // exception, which would have been ignored. We're doing the same now by catching and handling it. + } catch (IOException e) { + logger.log(Level.WARNING, "RegisterNewHandle failed.", e); + } } } - public Throwable registerNewHandle(DvObject dvObject) { + public void registerNewHandle(DvObject dvObject) throws IOException { logger.log(Level.FINE,"registerNewHandle"); String handlePrefix = dvObject.getAuthority(); String handle = getDvObjectHandle(dvObject); @@ -180,14 +187,12 @@ public Throwable registerNewHandle(DvObject dvObject) { AbstractResponse response = resolver.processRequest(req); if (response.responseCode == AbstractMessage.RC_SUCCESS) { logger.log(Level.INFO, "Success! Response: \n{0}", response); - return null; } else { logger.log(Level.WARNING, "RegisterNewHandle failed. Error response: {0}", response); - return new Exception("registerNewHandle failed: " + response); + throw new IOException("registerNewHandle failed: " + response); } - } catch (Throwable t) { - logger.log(Level.WARNING, "registerNewHandle failed", t); - return t; + } catch (HandleException t) { + throw new IOException("registerNewHandle failed", t); } } @@ -388,10 +393,8 @@ public List getProviderInformation(){ @Override - public String createIdentifier(DvObject dvObject) throws Throwable { - Throwable result = registerNewHandle(dvObject); - if (result != null) - throw result; + public String createIdentifier(DvObject dvObject) throws IOException { + registerNewHandle(dvObject); // TODO get exceptions from under the carpet return getDvObjectHandle(dvObject); diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java b/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java index b57fe1dcd5d..177a0627eda 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Dataverses.java @@ -14,6 +14,7 @@ import edu.harvard.iq.dataverse.api.auth.AuthRequired; import edu.harvard.iq.dataverse.api.datadeposit.SwordServiceBean; import edu.harvard.iq.dataverse.api.dto.DataverseMetadataBlockFacetDTO; +import edu.harvard.iq.dataverse.api.errorhandlers.ConstraintViolationExceptionHandler; import edu.harvard.iq.dataverse.authorization.DataverseRole; import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.GlobalId; @@ -73,6 +74,7 @@ import edu.harvard.iq.dataverse.engine.command.impl.UpdateDataverseMetadataBlocksCommand; import edu.harvard.iq.dataverse.engine.command.impl.UpdateExplicitGroupCommand; import edu.harvard.iq.dataverse.engine.command.impl.UpdateMetadataBlockFacetsCommand; +import edu.harvard.iq.dataverse.pidproviders.VersionPidMode.CollectionConduct; import edu.harvard.iq.dataverse.settings.JvmSettings; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; import edu.harvard.iq.dataverse.util.BundleUtil; @@ -82,17 +84,23 @@ import edu.harvard.iq.dataverse.util.json.JSONLDUtil; import edu.harvard.iq.dataverse.util.json.JsonParseException; +import edu.harvard.iq.dataverse.util.json.JsonPrinter; + import static edu.harvard.iq.dataverse.util.json.JsonPrinter.brief; import java.io.StringReader; import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.Set; import java.util.TreeSet; import java.util.logging.Level; import java.util.logging.Logger; +import javax.annotation.Resource; import javax.ejb.EJB; +import javax.ejb.EJBContext; import javax.ejb.EJBException; import javax.ejb.Stateless; +import javax.inject.Inject; import javax.json.Json; import javax.json.JsonArrayBuilder; import javax.json.JsonNumber; @@ -102,7 +110,10 @@ import javax.json.JsonValue; import javax.json.JsonValue.ValueType; import javax.json.stream.JsonParsingException; +import javax.validation.ConstraintViolation; import javax.validation.ConstraintViolationException; +import javax.validation.Validator; +import javax.validation.constraints.NotNull; import javax.ws.rs.BadRequestException; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; @@ -166,7 +177,13 @@ public class Dataverses extends AbstractApiBean { @EJB SwordServiceBean swordService; - + + @Inject + Validator validator; + + @Resource + EJBContext ejbCtxt; + @POST @AuthRequired public Response addRoot(@Context ContainerRequestContext crc, String body) { @@ -589,7 +606,76 @@ public Response deleteDataverse(@Context ContainerRequestContext crc, @PathParam return ok("Dataverse " + idtf + " deleted"); }, getRequestUser(crc)); } - + + /** + * Endpoint to change attributes of a Dataverse collection. + * + * @apiNote Example curl command: + * curl -X PUT -d "test" http://localhost:8080/api/dataverses/$ALIAS/attribute/alias + * to change the alias of the collection named $ALIAS to "test". + */ + @PUT + @AuthRequired + @Path("{identifier}/attribute/{attribute}") + public Response updateAttribute(@Context ContainerRequestContext crc, @PathParam("identifier") String identifier, + @PathParam("attribute") String attribute, @NotNull String value) { + try { + Dataverse collection = findDataverseOrDie(identifier); + User user = getRequestUser(crc); + DataverseRequest dvRequest = createDataverseRequest(user); + + // TODO: The cases below use hard coded strings, because we have no place for definitions of those! + // They are taken from util.json.JsonParser / util.json.JsonPrinter. This shall be changed. + // This also should be extended to more attributes, like the type, theme, contacts, some booleans, etc. + switch (attribute) { + case "alias": + collection.setAlias(value); + break; + case "name": + collection.setName(value); + break; + case "description": + collection.setDescription(value); + break; + case "affiliation": + collection.setAffiliation(value); + break; + case "versionPidsConduct": + CollectionConduct conduct = CollectionConduct.findBy(value); + if (conduct == null) { + return badRequest("'" + value + "' is not one of [" + + String.join(",", CollectionConduct.asList()) + "]"); + } + collection.setDatasetVersionPidConduct(conduct); + break; + default: + return badRequest("'" + attribute + "' is not a supported attribute"); + } + + // Validate now to avoid hubbub in Command Engine + Set> violations = validator.validate(collection); + if (!violations.isEmpty()) { + // TODO: This is an ugly hack to avoid the EJB transaction this endpoint method is automatically + // wrapped into (because this an EJB bean) trying to persist our changes from above to the + // database on it's on (which would obviously fail!) Usually, we would throw an exception + // to trigger the rollback, but that would cause noisy logs about them from the EJB container. + ejbCtxt.setRollbackOnly(); + return ConstraintViolationExceptionHandler.createResponse(violations); + } + + // Off to persistence layer + execCommand(new UpdateDataverseCommand(collection, null, null, dvRequest, null)); + + // Also return modified collection to user + return ok("Update successful", JsonPrinter.json(collection)); + + // TODO: This is an anti-pattern, necessary due to this bean being an EJB, causing very noisy and unnecessary + // logging by the EJB container for bubbling exceptions. (It would be handled by the error handlers.) + } catch (WrappedResponse e) { + return e.getResponse(); + } + } + @DELETE @AuthRequired @Path("{linkingDataverseId}/deleteLink/{linkedDataverseId}") @@ -1343,5 +1429,5 @@ public Response linkDataverse(@Context ContainerRequestContext crc, @PathParam(" return ex.getResponse(); } } - + } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ConstraintViolationExceptionHandler.java b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ConstraintViolationExceptionHandler.java index 4cbf31d1d2c..7eb59bb268b 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ConstraintViolationExceptionHandler.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/errorhandlers/ConstraintViolationExceptionHandler.java @@ -1,64 +1,59 @@ package edu.harvard.iq.dataverse.api.errorhandlers; -import edu.harvard.iq.dataverse.util.json.JsonPrinter; - import javax.json.Json; import javax.json.JsonArray; import javax.json.JsonArrayBuilder; -import javax.json.JsonObject; import javax.validation.ConstraintViolation; import javax.validation.ConstraintViolationException; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; -import java.util.List; -import java.util.stream.Collectors; +import java.util.Set; @Provider public class ConstraintViolationExceptionHandler implements ExceptionMapper { - public class ValidationError { - private String path; - private String message; - - public String getPath() { return path; } - public void setPath(String path) { this.path = path; } - public String getMessage() { return message; } - public void setMessage(String message) { this.message = message; } - } - @Override public Response toResponse(ConstraintViolationException exception) { + JsonArrayBuilder builder = Json.createArrayBuilder(); - List errors = exception.getConstraintViolations().stream() - .map(this::toValidationError) - .collect(Collectors.toList()); + // This hack and code duplication is necessary as there is no way to make createResponse(Set>) + // also accept Set>. So we split by creating the JsonArray before the Response. + exception.getConstraintViolations().forEach(violation -> builder.add( + Json.createObjectBuilder() + .add("path", violation.getPropertyPath().toString()) + .add("message", violation.getMessage()) + .add("invalidValue", violation.getInvalidValue() == null ? "null" : violation.getInvalidValue().toString()))); - return Response.status(Response.Status.BAD_REQUEST) - .entity( Json.createObjectBuilder() - .add("status", "ERROR") - .add("code", Response.Status.BAD_REQUEST.getStatusCode()) - .add("message", "JPA validation constraints failed persistence. See list of violations for details.") - .add("violations", toJsonArray(errors)) - .build()) - .type(MediaType.APPLICATION_JSON_TYPE).build(); + return createResponse(builder.build()); } - private ValidationError toValidationError(ConstraintViolation constraintViolation) { - ValidationError error = new ValidationError(); - error.setPath(constraintViolation.getPropertyPath().toString()); - error.setMessage(constraintViolation.getMessage()); - return error; + /** + * Create a nice JSON based response from a set of constraint violations. + * @param violations The violations (will be transformed to JSON objects) + * @return A {@link Response} for JAX-RS, containing a JSON based description of the problems + */ + public static Response createResponse(Set> violations) { + JsonArrayBuilder builder = Json.createArrayBuilder(); + + violations.forEach(violation -> builder.add( + Json.createObjectBuilder() + .add("path", violation.getPropertyPath().toString()) + .add("message", violation.getMessage()) + .add("invalidValue", violation.getInvalidValue() == null ? "null" : violation.getInvalidValue().toString()))); + + return createResponse(builder.build()); } - private JsonArray toJsonArray(List list) { - JsonArrayBuilder builder = Json.createArrayBuilder(); - list.stream() - .forEach(error -> builder.add( - Json.createObjectBuilder() - .add("path", error.getPath()) - .add("message", error.getMessage()))); - return builder.build(); + private static Response createResponse(JsonArray violations) { + return Response.status(Response.Status.BAD_REQUEST) + .entity( Json.createObjectBuilder() + .add("status", "ERROR") + .add("code", Response.Status.BAD_REQUEST.getStatusCode()) + .add("message", "JPA validation constraints failed persistence. See list of violations for details.") + .add("violations", violations) + .build()) + .type(MediaType.APPLICATION_JSON_TYPE).build(); } } \ No newline at end of file diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/NotImplementedException.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/NotImplementedException.java new file mode 100644 index 00000000000..1879d113bd1 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/exception/NotImplementedException.java @@ -0,0 +1,23 @@ +package edu.harvard.iq.dataverse.engine.command.exception; + +import javax.ejb.ApplicationException; + + +/** + * This is a copycat of the Apache Commons Lang3 exception but with an EJB annotation to make this runtime ("unchecked") + * exception not fail on the remote end of an EJB, but catchable in the client (the commands committed to the engine). + * By using this exception, the transaction is not yet rolled back, and we can react to events in other components + * within the command (it might not be necessary to rollback, etc). + * + * @apiNote This exception should be added to a future Dataverse Java API module + */ +@ApplicationException +public class NotImplementedException extends UnsupportedOperationException { + public NotImplementedException() { + super(); + } + + public NotImplementedException(String message) { + super(message); + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/FinalizeDatasetPublicationCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/FinalizeDatasetPublicationCommand.java index b2d7712721b..a1a36b9af94 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/FinalizeDatasetPublicationCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/FinalizeDatasetPublicationCommand.java @@ -7,6 +7,7 @@ import edu.harvard.iq.dataverse.DatasetFieldConstant; import edu.harvard.iq.dataverse.DatasetLock; import static edu.harvard.iq.dataverse.DatasetVersion.VersionState.*; +import edu.harvard.iq.dataverse.DatasetVersion; import edu.harvard.iq.dataverse.DatasetVersionUser; import edu.harvard.iq.dataverse.Dataverse; import edu.harvard.iq.dataverse.DvObject; @@ -18,6 +19,7 @@ import edu.harvard.iq.dataverse.engine.command.DataverseRequest; import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.NotImplementedException; import edu.harvard.iq.dataverse.export.ExportService; import edu.harvard.iq.dataverse.privateurl.PrivateUrl; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; @@ -27,6 +29,7 @@ import java.sql.Timestamp; import java.util.Date; import java.util.List; +import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; import edu.harvard.iq.dataverse.GlobalIdServiceBean; @@ -37,9 +40,6 @@ import java.util.concurrent.Future; import org.apache.solr.client.solrj.SolrServerException; -import javax.ejb.EJB; -import javax.inject.Inject; - /** * @@ -101,8 +101,7 @@ public Dataset execute(CommandContext ctxt) throws CommandException { try { // This can potentially throw a CommandException, so let's make // sure we exit cleanly: - - registerExternalIdentifier(theDataset, ctxt, false); + registerExternalIdentifier(theDataset, ctxt, false); } catch (CommandException comEx) { logger.warning("Failed to reserve the identifier "+theDataset.getGlobalId().asString()+"; notifying the user(s), unlocking the dataset"); // Send failure notification to the user: @@ -180,10 +179,43 @@ public Dataset execute(CommandContext ctxt) throws CommandException { ctxt.engine().submit(new DeletePrivateUrlCommand(getRequest(), theDataset)); } - if (theDataset.getLatestVersion().getVersionState() != RELEASED) { + if (theDataset.getLatestVersion().getVersionState() != RELEASED) { // some imported datasets may already be released. - if (!datasetExternallyReleased) { + /* If activated, now is the right moment to create an identifier for this version, as the + * metadata has been all updated. In PublishDatasetCommand (which comes before this command), + * the numbers have already been adapted, so we know if this will be a major or minor version. + * The identifier for the dataset has definitely been created (or this would have failed above), + * so we can include it in any metadata records for this version. + * + * Note: although it may seem wrong to trigger registering any version, it is a (configurable?) + * decision in the provider's realm if minor versions are just updates of a major or their own. + */ + DatasetVersion version = theDataset.getLatestVersion(); + + if (ctxt.dataverses().wantsDatasetVersionPids(theDataset.getOwner(), version.getMinorVersionNumber() > 0)) { + GlobalIdServiceBean idServiceBean = GlobalIdServiceBean.getBean(theDataset.getProtocol(), ctxt); + Objects.requireNonNull(idServiceBean, "Could not retrieve PID provider"); + + try { + // Generate an identifier for the version + String identifier = idServiceBean.generateDatasetVersionIdentifier(version); + version.setPersistentIdentifier(identifier); + + // Try to register the identifier with the provider + idServiceBean.createIdentifier(version); + version.setIdentifierRegistered(); + } catch (IOException | NotImplementedException e) { + logger.warning("Failed to register the version identifier "+version.getGlobalId().asString()+"; notifying the user(s), unlocking the dataset"); + + // Send failure notification to the user: + notifyUsersDatasetPublishStatus(ctxt, theDataset, UserNotification.Type.PUBLISHFAILED_PIDREG); + + ctxt.datasets().removeDatasetLocks(theDataset, DatasetLock.Reason.finalizePublication); + throw new CommandException(BundleUtil.getStringFromBundle("dataset.publish.error", idServiceBean.getProviderInformation()), this); + } + } + publicizeExternalIdentifier(theDataset, ctxt); // Will throw a CommandException, unless successful. // This will end the execution of the command, but the method @@ -365,8 +397,10 @@ private void publicizeExternalIdentifier(Dataset dataset, CommandContext ctxt) t try { String currentGlobalIdProtocol = ctxt.settings().getValueForKey(SettingsServiceBean.Key.Protocol, ""); String currentGlobalAuthority = ctxt.settings().getValueForKey(SettingsServiceBean.Key.Authority, ""); + String dataFilePIDFormat = ctxt.settings().getValueForKey(SettingsServiceBean.Key.DataFilePIDFormat, "DEPENDENT"); boolean isFilePIDsEnabled = ctxt.systemConfig().isFilePIDsEnabled(); + // We will skip trying to register the global identifiers for datafiles // if "dependent" file-level identifiers are requested, AND the naming // protocol, or the authority of the dataset global id is different from @@ -392,6 +426,15 @@ private void publicizeExternalIdentifier(Dataset dataset, CommandContext ctxt) t df.setIdentifierRegistered(true); } } + + // Publish a PID for this dataset version (if activated). Leave details to the provider. + + if (ctxt.dataverses().wantsDatasetVersionPids(dataset.getOwner(), dataset.getLatestVersion().getMinorVersionNumber() > 0)) { + DatasetVersion version = dataset.getLatestVersion(); + idServiceBean.publicizeIdentifier(version); + } + + // Publish the Dataset PID (after the version, because we want to include the version in metadata updates) if (!idServiceBean.publicizeIdentifier(dataset)) { throw new Exception(); } diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterDvObjectCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterDvObjectCommand.java index 6da3bf0ad84..6dc759aeb7e 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterDvObjectCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RegisterDvObjectCommand.java @@ -3,6 +3,7 @@ import edu.harvard.iq.dataverse.AlternativePersistentIdentifier; import edu.harvard.iq.dataverse.DataFile; import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.Dataverse; import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.GlobalId; import edu.harvard.iq.dataverse.engine.command.AbstractVoidCommand; @@ -83,6 +84,15 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { target.setGlobalIdCreateTime(new Timestamp(new Date().getTime())); } if (target.isReleased()) { + // If this is a dataset, release a new dataset version first before, to be included in the dataset + if (target.isInstanceofDataset() && + ctxt.dataverses().wantsDatasetVersionPids( + (Dataverse) target.getOwner(), + ((Dataset) target).getLatestVersion().getMinorVersionNumber() > 0)) { + // TODO: publicize dataset version as well + } + + // Now publicize the actual object idServiceBean.publicizeIdentifier(target); } if (idServiceBean.registerWhenPublished() && target.isReleased()) { diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDvObjectPIDMetadataCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDvObjectPIDMetadataCommand.java index 7e37241563c..7353a383c8d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDvObjectPIDMetadataCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/UpdateDvObjectPIDMetadataCommand.java @@ -2,6 +2,7 @@ import edu.harvard.iq.dataverse.DataFile; import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.Dataverse; import edu.harvard.iq.dataverse.GlobalIdServiceBean; import edu.harvard.iq.dataverse.authorization.Permission; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; @@ -48,7 +49,14 @@ protected void executeImpl(CommandContext ctxt) throws CommandException { } GlobalIdServiceBean idServiceBean = GlobalIdServiceBean.getBean(target.getProtocol(), ctxt); try { + // First publicize new dataset version if enabled, so it can be included in the update of the dataset + if (ctxt.dataverses().wantsDatasetVersionPids(target.getOwner(), target.getLatestVersion().getMinorVersionNumber() > 0)) { + // TODO: publicize dataset version as well + } + + // Publicize the dataset Boolean doiRetString = idServiceBean.publicizeIdentifier(target); + if (doiRetString) { target.setGlobalIdCreateTime(new Timestamp(new Date().getTime())); ctxt.em().merge(target); diff --git a/src/main/java/edu/harvard/iq/dataverse/pidproviders/FakePidProviderServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/pidproviders/FakePidProviderServiceBean.java index 68dd853d4de..f872af53e19 100644 --- a/src/main/java/edu/harvard/iq/dataverse/pidproviders/FakePidProviderServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/pidproviders/FakePidProviderServiceBean.java @@ -1,8 +1,11 @@ package edu.harvard.iq.dataverse.pidproviders; import edu.harvard.iq.dataverse.DOIServiceBean; +import edu.harvard.iq.dataverse.DatasetVersion; import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.GlobalId; + +import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -43,7 +46,7 @@ public List getProviderInformation() { } @Override - public String createIdentifier(DvObject dvo) throws Throwable { + public String createIdentifier(DvObject dvo) throws IOException { return "fakeIdentifier"; } @@ -72,5 +75,15 @@ public boolean publicizeIdentifier(DvObject studyIn) { protected String getProviderKeyName() { return "FAKE"; } - + + + @Override + public String createIdentifier(DatasetVersion datasetVersion) throws IOException { + return "fakeVersionIdentifier"; + } + + @Override + public boolean publicizeIdentifier(DatasetVersion datasetVersion) throws IOException { + return true; + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/pidproviders/PermaLinkPidProviderServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/pidproviders/PermaLinkPidProviderServiceBean.java index 957522b7728..b5dfebd96f5 100644 --- a/src/main/java/edu/harvard/iq/dataverse/pidproviders/PermaLinkPidProviderServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/pidproviders/PermaLinkPidProviderServiceBean.java @@ -9,6 +9,7 @@ import edu.harvard.iq.dataverse.settings.SettingsServiceBean.Key; import edu.harvard.iq.dataverse.util.SystemConfig; +import java.io.IOException; import java.lang.StackWalker.StackFrame; import java.util.ArrayList; import java.util.HashMap; @@ -84,7 +85,7 @@ public List getProviderInformation() { } @Override - public String createIdentifier(DvObject dvo) throws Throwable { + public String createIdentifier(DvObject dvo) throws IOException { //Call external resolver and send landing URL? //FWIW: Return value appears to only be used in RegisterDvObjectCommand where success requires finding the dvo identifier in this string. (Also logged a couple places). return(dvo.getGlobalId().asString()); diff --git a/src/main/java/edu/harvard/iq/dataverse/pidproviders/UnmanagedDOIServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/pidproviders/UnmanagedDOIServiceBean.java index 088992fd3ec..5e3203a5156 100644 --- a/src/main/java/edu/harvard/iq/dataverse/pidproviders/UnmanagedDOIServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/pidproviders/UnmanagedDOIServiceBean.java @@ -48,7 +48,7 @@ public boolean alreadyExists(GlobalId pid) { } @Override - public String createIdentifier(DvObject dvObject) throws Exception { + public String createIdentifier(DvObject dvObject) throws IOException { throw new NotImplementedException(); } diff --git a/src/main/java/edu/harvard/iq/dataverse/pidproviders/UnmanagedHandlenetServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/pidproviders/UnmanagedHandlenetServiceBean.java index c467b8672ee..0890c021279 100644 --- a/src/main/java/edu/harvard/iq/dataverse/pidproviders/UnmanagedHandlenetServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/pidproviders/UnmanagedHandlenetServiceBean.java @@ -4,6 +4,8 @@ import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.GlobalId; import edu.harvard.iq.dataverse.HandlenetServiceBean; + +import java.io.IOException; import java.util.*; import java.util.logging.Level; import java.util.logging.Logger; @@ -60,7 +62,7 @@ public List getProviderInformation() { } @Override - public String createIdentifier(DvObject dvObject) throws Throwable { + public String createIdentifier(DvObject dvObject) throws IOException { throw new NotImplementedException(); } diff --git a/src/main/java/edu/harvard/iq/dataverse/pidproviders/VersionPidMode.java b/src/main/java/edu/harvard/iq/dataverse/pidproviders/VersionPidMode.java new file mode 100644 index 00000000000..71fb5474d18 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/pidproviders/VersionPidMode.java @@ -0,0 +1,159 @@ +package edu.harvard.iq.dataverse.pidproviders; + +import edu.harvard.iq.dataverse.Dataverse; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Enumlike class to bundle available options for PIDs of Dataset Versions. + * Must be a class to ensure case-insensitive configuration values (not possible/reliable with enum) + */ +public final class VersionPidMode { + + /** + * Means feature is switched off, no version PIDs will be minted + */ + public static final VersionPidMode OFF = new VersionPidMode("off"); + + /** + * Means the feature is activated, but instance wide for all collections and datasets + * only major versions can have a PID. Enabling for minor versions is prohibited. + */ + public static final VersionPidMode ALLOW_MAJOR = new VersionPidMode("allow-major"); + + /** + * Means the feature is activated and any collection may go for PIDs assigned to major and/or minor versions. + */ + public static final VersionPidMode ALLOW_MINOR = new VersionPidMode("allow-minor"); + + /** + * A collection of conducts for Dataverse collections, used in {@link edu.harvard.iq.dataverse.Dataverse} + * and {@link edu.harvard.iq.dataverse.DataverseServiceBean#wantsDatasetVersionPids(Dataverse, boolean)}: + *
    + *
  1. Collection may inherit version pid behaviour from the parent collection(s),
  2. + *
  3. collection may choose to opt out and skip the minting,
  4. + *
  5. collection may choose to activate it for major versions only, or
  6. + *
  7. collection may choose to activate it for major and minor versions.
  8. + *
+ * Note that the instance wide configuration will limit available choices. + */ + public enum CollectionConduct { + INHERIT("inherit"), + SKIP("skip"), + MAJOR("major"), + MINOR("minor"); + + private final String name; + + CollectionConduct(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.name; + } + + public static CollectionConduct findBy(String name) { + return Arrays.stream(CollectionConduct.values()) + .filter(cs -> cs.name.equalsIgnoreCase(name)) + .findFirst() + .orElse(null); + } + + public static List asList() { + return values.stream().map(Object::toString).collect(Collectors.toList()); + } + } + + /** + * Defining a list of styles how to generate the version PIDs. + * This is not extensible from the outside on purpose. + * This is a class to enable auto-conversion via MicroProfile Config + * also with lowercase setting values. + */ + public static final class GenStyle { + + public static final GenStyle DATASET = new GenStyle("DATASET"); + public static final GenStyle SUFFIX = new GenStyle("SUFFIX"); + + // Init as unmodifiable set + public static final Set values; + static { + values = Set.of(DATASET, SUFFIX); + } + + private final String style; + + GenStyle(String style) { + this.style = style; + } + + @Override + public String toString() { + return this.style; + } + + public static GenStyle of(String style) { + return values.stream() + .filter(m -> m.style.equalsIgnoreCase(style)) + .findAny() + .orElse(null); + } + } + + + // Init as unmodifiable set + public static final Set values; + static { + values = Set.of(OFF, ALLOW_MAJOR, ALLOW_MINOR); + } + + private final String mode; + + // Hide the no-arg constructor - no one shall get fancy ideas of extension. + private VersionPidMode() { + this.mode = ""; + } + + // Hide the constructor - no one shall get fancy ideas of extension. + private VersionPidMode(String mode) { + this.mode = mode; + } + + /** + * Used to enable auto-conversion for MicroProfile Config. + * Comparison is done case-insensitive to enable all variants of writing the config value. + * + * Note that a non-matching value will return null, which will trigger a {@link java.util.NoSuchElementException} + * for the conversion. + * + * @see MicroProfile Config Spec for Autoconverts + * + * @param mode The mode to lookup + * @return A matching constant or null if not matching. + */ + public static VersionPidMode of(String mode) { + return values.stream() + .filter(m -> m.mode.equalsIgnoreCase(mode)) + .findAny() + .orElse(null); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof VersionPidMode)) return false; + VersionPidMode that = (VersionPidMode) o; + return mode.equalsIgnoreCase(that.mode); + } + + @Override + public int hashCode() { + return Objects.hash(mode); + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/JvmSettings.java b/src/main/java/edu/harvard/iq/dataverse/settings/JvmSettings.java index ff04a633ea7..3bb57d20b68 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/JvmSettings.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/JvmSettings.java @@ -108,7 +108,12 @@ public enum JvmSettings { SCOPE_PID_HANDLENET_KEY(SCOPE_PID_HANDLENET, "key"), HANDLENET_KEY_PATH(SCOPE_PID_HANDLENET_KEY, "path", "dataverse.handlenet.admcredfile"), HANDLENET_KEY_PASSPHRASE(SCOPE_PID_HANDLENET_KEY, "passphrase", "dataverse.handlenet.admprivphrase"), - + + // VERSION PID SETTINGS + SCOPE_PID_VERSIONS(SCOPE_PID, "version"), + PID_VERSIONS_MODE(SCOPE_PID_VERSIONS, "mode"), + PID_VERSIONS_STYLE(SCOPE_PID_VERSIONS, "style"), + // SPI SETTINGS SCOPE_SPI(PREFIX, "spi"), SCOPE_EXPORTERS(SCOPE_SPI, "exporters"), @@ -123,6 +128,7 @@ public enum JvmSettings { SCOPE_UI(PREFIX, "ui"), UI_ALLOW_REVIEW_INCOMPLETE(SCOPE_UI, "allow-review-for-incomplete"), UI_SHOW_VALIDITY_FILTER(SCOPE_UI, "show-validity-filter"), + ; private static final String SCOPE_SEPARATOR = "."; diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java index 4fe9654cc64..130f84dbd18 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonParser.java @@ -28,6 +28,8 @@ import edu.harvard.iq.dataverse.harvest.client.HarvestingClient; import edu.harvard.iq.dataverse.license.License; import edu.harvard.iq.dataverse.license.LicenseServiceBean; +import edu.harvard.iq.dataverse.pidproviders.VersionPidMode; +import edu.harvard.iq.dataverse.pidproviders.VersionPidMode.CollectionConduct; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.workflow.Workflow; @@ -125,6 +127,10 @@ public Dataverse parseDataverse(JsonObject jobj) throws JsonParseException { dv.setPermissionRoot(jobj.getBoolean("permissionRoot", false)); dv.setFacetRoot(jobj.getBoolean("facetRoot", false)); dv.setAffiliation(jobj.getString("affiliation", null)); + dv.setDatasetVersionPidConduct( + CollectionConduct.findBy( + jobj.getString("versionPidsConduct", CollectionConduct.INHERIT.toString()) + )); if (jobj.containsKey("dataverseContacts")) { JsonArray dvContacts = jobj.getJsonArray("dataverseContacts"); diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java index e9e8fcd1a90..d1af39d2015 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java @@ -285,7 +285,8 @@ public static JsonObjectBuilder json(Dataverse dv, Boolean hideEmail) { .add("id", dv.getId()) .add("alias", dv.getAlias()) .add("name", dv.getName()) - .add("affiliation", dv.getAffiliation()); + .add("affiliation", dv.getAffiliation()) + .add("versionPidsConduct", dv.getDatasetVersionPidConduct().toString()); if(!hideEmail) { bld.add("dataverseContacts", JsonPrinter.json(dv.getDataverseContacts())); } @@ -371,6 +372,8 @@ public static JsonObjectBuilder json(DatasetVersion dsv) { JsonObjectBuilder bld = jsonObjectBuilder() .add("id", dsv.getId()).add("datasetId", dsv.getDataset().getId()) .add("datasetPersistentId", dsv.getDataset().getGlobalId().asString()) + // This might be null if this version does not have a version, the builder will silently omit it then. + .add("persistentId", dsv.getGlobalId().asString()) .add("storageIdentifier", dsv.getDataset().getStorageIdentifier()) .add("versionNumber", dsv.getVersionNumber()).add("versionMinorNumber", dsv.getMinorVersionNumber()) .add("versionState", dsv.getVersionState().name()).add("versionNote", dsv.getVersionNote()) diff --git a/src/main/resources/db/migration/V5.14.0.1__4499-dataset-version-pids.sql b/src/main/resources/db/migration/V5.14.0.1__4499-dataset-version-pids.sql new file mode 100644 index 00000000000..36a335160da --- /dev/null +++ b/src/main/resources/db/migration/V5.14.0.1__4499-dataset-version-pids.sql @@ -0,0 +1,5 @@ +ALTER TABLE dataverse ADD COLUMN IF NOT EXISTS datasetVersionPidConduct varchar(16); + +ALTER TABLE datasetVersion ADD COLUMN IF NOT EXISTS persistentIdentifier varchar(255); + +ALTER TABLE datasetVersion ADD COLUMN IF NOT EXISTS identifierRegistered bool NOT NULL DEFAULT false; diff --git a/src/test/java/edu/harvard/iq/dataverse/AbstractGlobalIdServiceBeanTest.java b/src/test/java/edu/harvard/iq/dataverse/AbstractGlobalIdServiceBeanTest.java new file mode 100644 index 00000000000..c89def770bc --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/AbstractGlobalIdServiceBeanTest.java @@ -0,0 +1,188 @@ +package edu.harvard.iq.dataverse; + +import edu.harvard.iq.dataverse.mocks.MocksFactory; +import edu.harvard.iq.dataverse.settings.JvmSettings; +import edu.harvard.iq.dataverse.util.testing.JvmSetting; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +import java.io.IOException; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class AbstractGlobalIdServiceBeanTest { + + private static final int idLen = 8; + + @Test + @JvmSetting(key = JvmSettings.PID_VERSIONS_STYLE, value = "dataset") + void generateDatasetVersionIdentifierWithStyleDataset() { + // given + TestIdService sut = new TestIdService(); + Dataset dataset = MocksFactory.makeDataset(); + String datasetIdentifier = dataset.getIdentifier(); + + // We assume that this code will be called with a dataset version that is about to be published + // and thus is not in status released. The random generated dataset is assumed to have the version number + // "1", so we can compare things properly. + assumeFalse(dataset.getLatestVersion().isReleased()); + assumeTrue(dataset.getLatestVersion().getVersionNumber() == 1); + assumeTrue(dataset.getLatestVersion().getMinorVersionNumber() == 0); + + // when + String versionIdentifier = sut.generateDatasetVersionIdentifier(dataset.getLatestVersion()); + + // then + assertNotEquals(datasetIdentifier, versionIdentifier); + assertFalse(versionIdentifier.contains(".")); + assertEquals(idLen, versionIdentifier.length()); + } + + @Test + @JvmSetting(key = JvmSettings.PID_VERSIONS_STYLE, value = "suffix") + void generateDatasetVersionIdentifierWithStyleSuffix() { + // given + TestIdService sut = new TestIdService(); + Dataset dataset = MocksFactory.makeDataset(); + String datasetIdentifier = dataset.getIdentifier(); + + // We assume that this code will be called with a dataset version that is about to be published + // and thus is not in status released. The random generated dataset is assumed to have the version number + // "1", so we can compare things properly. + assumeFalse(dataset.getLatestVersion().isReleased()); + assumeTrue(dataset.getLatestVersion().getVersionNumber() == 1); + assumeTrue(dataset.getLatestVersion().getMinorVersionNumber() == 0); + + // when + String versionIdentifier = sut.generateDatasetVersionIdentifier(dataset.getLatestVersion()); + + // then + assertNotEquals(datasetIdentifier, versionIdentifier); + assertTrue(versionIdentifier.contains(".")); + assertEquals(versionIdentifier, datasetIdentifier + ".1"); + } + + @Test + void generateDatasetVersionIdentifierReturnsReleasedIdWithMinorVersion() { + // given + TestIdService sut = new TestIdService(); + Dataset dataset = MocksFactory.makeDataset(); + + // Release the first version + dataset.getLatestVersion().setReleaseTime(new Date()); + dataset.getLatestVersion().setVersionState(DatasetVersion.VersionState.RELEASED); + dataset.getLatestVersion().setPersistentIdentifier("test"); + assumeTrue(dataset.getLatestVersion().isReleased()); + + // Add a new minor version + DatasetVersion newVersion = dataset.getOrCreateEditVersion(); + assumeFalse(dataset.getLatestVersion().isReleased()); + dataset.getLatestVersion().setMinorVersionNumber(1L); + assumeTrue(newVersion.getMinorVersionNumber() == 1); + + // when + String identifier = sut.generateDatasetVersionIdentifier(dataset.getLatestVersion()); + assertEquals("test", identifier); + } + + @Test + void generateDatasetVersionIdentifierFailsWithReleasedVersion() { + // given + TestIdService sut = new TestIdService(); + Dataset dataset = MocksFactory.makeDataset(); + String datasetIdentifier = dataset.getIdentifier(); + + assumeTrue(dataset.getLatestVersion().getMinorVersionNumber() == 0); + + // Set to non-allowed combination + dataset.getLatestVersion().setVersionState(DatasetVersion.VersionState.RELEASED); + + // when & then (split retrieval to avoid ambiguous lambda exceptions) + DatasetVersion version = dataset.getLatestVersion(); + assertThrows(IllegalArgumentException.class, () -> sut.generateDatasetVersionIdentifier(version)); + } + + @ParameterizedTest + @JvmSetting(key = JvmSettings.PID_VERSIONS_STYLE, value = "suffix") + @NullAndEmptySource + void generateDatasetVersionIdentifierFailsWithSuffixStyleAndEmptyDatasetIdentifier(String pid) { + // given + TestIdService sut = new TestIdService(); + Dataset dataset = MocksFactory.makeDataset(); + + assumeTrue(dataset.getLatestVersion().getMinorVersionNumber() == 0); + + // Set to non-allowed combination + dataset.setIdentifier(pid); + + // when & then (split retrieval to avoid ambiguous lambda exceptions) + DatasetVersion version = dataset.getLatestVersion(); + assertThrows(IllegalArgumentException.class, () -> sut.generateDatasetVersionIdentifier(version)); + } + + + static class TestIdService extends AbstractGlobalIdServiceBean { + + @Override + public boolean alreadyExists(GlobalId globalId) throws Exception { + return false; + } + + @Override + public boolean registerWhenPublished() { + return false; + } + + @Override + public List getProviderInformation() { + return null; + } + + @Override + public String createIdentifier(DvObject dvo) throws IOException { + return RandomStringUtils.randomAlphanumeric(idLen).toUpperCase(); + } + + @Override + public String generateDatasetIdentifier(Dataset dataset) { + return RandomStringUtils.randomAlphanumeric(idLen).toUpperCase(); + } + + @Override + public Map getIdentifierMetadata(DvObject dvo) { + return null; + } + + @Override + public String modifyIdentifierTargetURL(DvObject dvo) throws Exception { + return null; + } + + @Override + public void deleteIdentifier(DvObject dvo) throws Exception { + + } + + @Override + public boolean publicizeIdentifier(DvObject studyIn) { + return false; + } + + @Override + public String getUrlPrefix() { + return null; + } + } + +} \ No newline at end of file diff --git a/src/test/java/edu/harvard/iq/dataverse/DataverseServiceBeanTest.java b/src/test/java/edu/harvard/iq/dataverse/DataverseServiceBeanTest.java new file mode 100644 index 00000000000..d503c6800bb --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/DataverseServiceBeanTest.java @@ -0,0 +1,119 @@ +package edu.harvard.iq.dataverse; + +import edu.harvard.iq.dataverse.mocks.MocksFactory; +import edu.harvard.iq.dataverse.settings.JvmSettings; +import edu.harvard.iq.dataverse.util.testing.JvmSetting; +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.MethodSource; + +import java.util.stream.Stream; + +import static edu.harvard.iq.dataverse.pidproviders.VersionPidMode.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DataverseServiceBeanTest { + + DataverseServiceBean dataverseServiceBean = new DataverseServiceBean(); + + private static final Dataverse root = MocksFactory.makeDataverse(); + private static final Dataverse intermediate = MocksFactory.makeDataverse(); + private static final Dataverse child = MocksFactory.makeDataverse(); + + @BeforeAll + static void setup() { + intermediate.setOwner(root); + child.setOwner(intermediate); + } + + static Stream versionPidCombinationsForAdminMajor() { + return Stream.of( + Arguments.of(false, CollectionConduct.SKIP, false), + Arguments.of(false, CollectionConduct.SKIP, true), + Arguments.of(true, CollectionConduct.MAJOR, false), + Arguments.of(false, CollectionConduct.MAJOR, true), + Arguments.of(true, CollectionConduct.MINOR, false), + Arguments.of(false, CollectionConduct.MINOR, true) + ); + } + + @ParameterizedTest + @MethodSource("versionPidCombinationsForAdminMajor") + @JvmSetting(key = JvmSettings.PID_VERSIONS_MODE, value = "allow-major") + void versionPidCollectionConductModesWithAdminMajorOnly(boolean expected, CollectionConduct rootConduct, boolean willBeMinor) { + // given + root.setDatasetVersionPidConduct(rootConduct); + + // when + assertEquals(expected, dataverseServiceBean.wantsDatasetVersionPids(child, willBeMinor)); + } + + static Stream versionPidCombinationsForAdminMinor() { + return Stream.of( + Arguments.of(false, CollectionConduct.SKIP, false), + Arguments.of(false, CollectionConduct.SKIP, true), + Arguments.of(true, CollectionConduct.MAJOR, false), + Arguments.of(false, CollectionConduct.MAJOR, true), + Arguments.of(true, CollectionConduct.MINOR, false), + Arguments.of(true, CollectionConduct.MINOR, true) + ); + } + + @ParameterizedTest + @MethodSource("versionPidCombinationsForAdminMinor") + @JvmSetting(key = JvmSettings.PID_VERSIONS_MODE, value = "allow-minor") + void versionPidCollectionConductModesWithAdminMinor(boolean expected, CollectionConduct rootConduct, boolean willBeMinor) { + // given + root.setDatasetVersionPidConduct(rootConduct); + + // when + assertEquals(expected, dataverseServiceBean.wantsDatasetVersionPids(child, willBeMinor)); + } + + @Test + void versionPidCollectionMayNotBeNull() { + assertThrows(NullPointerException.class, () -> dataverseServiceBean.wantsDatasetVersionPids(null, false)); + } + + @Test + @JvmSetting(key = JvmSettings.PID_VERSIONS_MODE, value = "off") + void versionPidCollectionAdminDisabled() { + assertFalse(dataverseServiceBean.wantsDatasetVersionPids(child, false)); + assertFalse(dataverseServiceBean.wantsDatasetVersionPids(child, true)); + } + + @Test + @JvmSetting(key = JvmSettings.PID_VERSIONS_MODE, value = "allow-major") + void versionPidCollectionAdminMajorOnly() { + assertFalse(dataverseServiceBean.wantsDatasetVersionPids(child, true)); + } + + @Test + @JvmSetting(key = JvmSettings.PID_VERSIONS_MODE, value = "allow-major") + void versionPidNoCollectionConductButAdminMajorOnly() { + // given + Dataverse collection = MocksFactory.makeDataverse(); + collection.setDatasetVersionPidConduct(CollectionConduct.INHERIT); + + // when & then + assertTrue(dataverseServiceBean.wantsDatasetVersionPids(collection, false)); + assertFalse(dataverseServiceBean.wantsDatasetVersionPids(collection, true)); + } + + @Test + @JvmSetting(key = JvmSettings.PID_VERSIONS_MODE, value = "allow-minor") + void versionPidNoCollectionConductButAdminMinor() { + // given + Dataverse collection = MocksFactory.makeDataverse(); + collection.setDatasetVersionPidConduct(CollectionConduct.INHERIT); + + // when & then + assertTrue(dataverseServiceBean.wantsDatasetVersionPids(collection, false)); + assertTrue(dataverseServiceBean.wantsDatasetVersionPids(collection, true)); + } +} \ No newline at end of file diff --git a/src/test/java/edu/harvard/iq/dataverse/pidproviders/VersionPidModeTest.java b/src/test/java/edu/harvard/iq/dataverse/pidproviders/VersionPidModeTest.java new file mode 100644 index 00000000000..37b57348906 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/pidproviders/VersionPidModeTest.java @@ -0,0 +1,30 @@ +package edu.harvard.iq.dataverse.pidproviders; + +import edu.harvard.iq.dataverse.settings.JvmSettings; +import edu.harvard.iq.dataverse.util.testing.JvmSetting; +import org.junit.jupiter.api.Test; + +import java.util.NoSuchElementException; + +import static org.junit.jupiter.api.Assertions.*; +class VersionPidModeTest { + + @Test + @JvmSetting(key = JvmSettings.PID_VERSIONS_MODE, value = "allow-minor") + void setToValidValue() { + assertEquals(VersionPidMode.ALLOW_MINOR, JvmSettings.PID_VERSIONS_MODE.lookup(VersionPidMode.class)); + } + + @Test + @JvmSetting(key = JvmSettings.PID_VERSIONS_MODE, value = "ALLOW-major") + void setToOtherValidValue() { + assertEquals(VersionPidMode.ALLOW_MAJOR, JvmSettings.PID_VERSIONS_MODE.lookup(VersionPidMode.class)); + } + + @Test + @JvmSetting(key = JvmSettings.PID_VERSIONS_MODE, value = "foobar") + void setToInvalidValue() { + assertThrows(NoSuchElementException.class, () -> JvmSettings.PID_VERSIONS_MODE.lookup(VersionPidMode.class)); + } + +} \ No newline at end of file