From 1bd231c700419efdddfe2b6985f00996413837e0 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Thu, 21 May 2026 13:31:56 +0200 Subject: [PATCH 1/9] fix(submission): separate CLARIN license payload from sections.license --- .../rest/model/step/DataClarinLicense.java | 80 +++++++++++ .../WorkspaceItemRestRepository.java | 56 +------- .../step/ClarinLicenseResourceStep.java | 124 +++++++++++++----- .../step/ClarinLicenseSubmissionUtils.java | 123 +++++++++++++++++ .../ClarinWorkspaceItemRestRepositoryIT.java | 78 +++++++++++ 5 files changed, 375 insertions(+), 86 deletions(-) create mode 100644 dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/DataClarinLicense.java create mode 100644 dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/DataClarinLicense.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/DataClarinLicense.java new file mode 100644 index 000000000000..bef5b3303a8f --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/DataClarinLicense.java @@ -0,0 +1,80 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.app.rest.model.step; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonProperty.Access; + +/** + * Java Bean exposing the CLARIN license selected for an in-progress submission. + *

+ * Unlike {@link DataLicense} which represents the distribution / deposit + * {@code LICENSE/license.txt} bitstream, this DTO represents the CLARIN + * resource license chosen by the user and stored in the item metadata + * ({@code dc.rights}, {@code dc.rights.uri}, {@code dc.rights.label}). + * + * @author Milan Majchrak (milan.majchrak at dataquest.sk) + */ +public class DataClarinLicense implements SectionData { + + /** + * Display name of the CLARIN license (value of {@code dc.rights}). + */ + private String name; + + /** + * URI / definition of the CLARIN license (value of {@code dc.rights.uri}). + */ + @JsonProperty(access = Access.READ_ONLY) + private String definition; + + /** + * Short label of the CLARIN license (value of {@code dc.rights.label}). + */ + @JsonProperty(access = Access.READ_ONLY) + private String label; + + /** + * Whether the CLARIN license is granted, i.e. all three metadata fields + * ({@code dc.rights}, {@code dc.rights.uri}, {@code dc.rights.label}) are + * present on the item. + */ + private boolean granted = false; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDefinition() { + return definition; + } + + public void setDefinition(String definition) { + this.definition = definition; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public boolean isGranted() { + return granted; + } + + public void setGranted(boolean granted) { + this.granted = granted; + } +} diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java index a229d8b8c8c7..bd7586a2d3e4 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java @@ -31,7 +31,6 @@ import org.dspace.app.rest.Parameter; import org.dspace.app.rest.SearchRestMethod; import org.dspace.app.rest.converter.WorkspaceItemConverter; -import org.dspace.app.rest.exception.ClarinLicenseNotFoundException; import org.dspace.app.rest.exception.DSpaceBadRequestException; import org.dspace.app.rest.exception.RepositoryMethodNotImplementedException; import org.dspace.app.rest.exception.UnprocessableEntityException; @@ -51,14 +50,11 @@ import org.dspace.app.util.SubmissionStepConfig; import org.dspace.authorize.AuthorizeException; import org.dspace.authorize.service.AuthorizeService; -import org.dspace.content.Bitstream; -import org.dspace.content.Bundle; import org.dspace.content.Collection; import org.dspace.content.Item; import org.dspace.content.LicenseUtils; import org.dspace.content.MetadataValue; import org.dspace.content.WorkspaceItem; -import org.dspace.content.clarin.ClarinLicense; import org.dspace.content.service.BitstreamFormatService; import org.dspace.content.service.BitstreamService; import org.dspace.content.service.CollectionService; @@ -555,53 +551,11 @@ private void maintainLicensesForItem(Context context, WorkspaceItem source, Oper clarinLicenseName = jsonNodeValue.asText(); } - // Get clarin license by definition - ClarinLicense clarinLicense = clarinLicenseService.findByName(context, clarinLicenseName); - if (StringUtils.isNotBlank(clarinLicenseName) && Objects.isNull(clarinLicense)) { - throw new ClarinLicenseNotFoundException("Cannot patch workspace item with id: " + source.getID() + "," + - " because the clarin license with name: " + clarinLicenseName + " isn't supported in" + - " the CLARIN/DSpace"); - } - - // Clear the license metadata from the item - clarinLicenseService.clearLicenseMetadataFromItem(context, item); - - // Detach the clarin licenses from the uploaded bitstreams - List bundles = item.getBundles(Constants.CONTENT_BUNDLE_NAME); - for (Bundle bundle : bundles) { - List bitstreamList = bundle.getBitstreams(); - for (Bitstream bitstream : bitstreamList) { - // in case bitstream ID exists in license table for some reason .. just remove it - this.clarinLicenseResourceMappingService.detachLicenses(context, bitstream); - } - } - - // Save changes to database - itemService.update(context, item); - - if (Objects.isNull(clarinLicense)) { - log.info("The clarin license is null so all item metadata for license was cleared and the" + - "licenses was detached."); - return; - } - - // If the clarin license is not null that means some clarin license was updated and accepted - // Attach the new clarin license to every bitstream and add clarin license values to the item metadata. - - // update item metadata with license data - clarinLicenseService.addLicenseMetadataToItem(context, clarinLicense, item); - - // Attach the clarin license to the bitstreams - for (Bundle bundle : bundles) { - List bitstreamList = bundle.getBitstreams(); - for (Bitstream bitstream : bitstreamList) { - // in case bitstream ID exists in license table for some reason .. just remove it - this.clarinLicenseResourceMappingService.attachLicense(context, clarinLicense, bitstream); - } - } - - // Save changes to database - itemService.update(context, item); + // Delegate to shared helper so the top-level `/license` path + // (legacy / backward-compat) and the new section patch path + // `/sections/clarin-license/name` apply the same logic. + org.dspace.app.rest.submit.step.ClarinLicenseSubmissionUtils + .applyLicense(context, item, clarinLicenseName); } private void grantDistributionLicense(Context context, WorkspaceItem source, Operation op) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java index 84babbbd7b27..3e56b2bd2561 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java @@ -7,53 +7,73 @@ */ package org.dspace.app.rest.submit.step; +import java.util.List; import javax.servlet.http.HttpServletRequest; -import org.atteo.evo.inflector.English; -import org.dspace.app.rest.exception.UnprocessableEntityException; -import org.dspace.app.rest.model.BitstreamRest; +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.commons.collections4.CollectionUtils; +import org.dspace.app.rest.model.patch.JsonValueEvaluator; import org.dspace.app.rest.model.patch.Operation; -import org.dspace.app.rest.model.step.DataLicense; +import org.dspace.app.rest.model.step.DataClarinLicense; import org.dspace.app.rest.submit.AbstractProcessingStep; import org.dspace.app.rest.submit.SubmissionService; -import org.dspace.app.rest.submit.factory.PatchOperationFactory; -import org.dspace.app.rest.submit.factory.impl.PatchOperation; import org.dspace.app.util.SubmissionStepConfig; -import org.dspace.content.Bitstream; import org.dspace.content.InProgressSubmission; -import org.dspace.core.Constants; +import org.dspace.content.Item; +import org.dspace.content.MetadataValue; import org.dspace.core.Context; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** - * Clarin License Resource License step for DSpace Spring Rest. This Step will show license selector - * where the user could choose license for the bitstream. + * CLARIN license resource step for DSpace Spring REST. + *

+ * Exposes the CLARIN license currently selected for the in-progress + * submission (sourced from the item's {@code dc.rights*} metadata) and + * accepts section-scoped patches under + * {@code /sections/clarin-license/name} to update that selection. + *

+ * This step is intentionally not a clone of + * {@code LicenseStep} any more — the CLARIN license has nothing to do with + * the distribution license stored in {@code LICENSE/license.txt}. * * @author Milan Majchrak (milan.majchrak at dataquest.sk) - * - * This class is inspired by the class LicenseStep created by - * @author Luigi Andrea Pascarelli (luigiandrea.pascarelli at 4science.it) - * */ public class ClarinLicenseResourceStep extends AbstractProcessingStep { - private static final String DCTERMS_RIGHTSDATE = "dcterms.accessRights"; + private static final Logger log = LoggerFactory.getLogger(ClarinLicenseResourceStep.class); + + /** + * Section patch path entry used to set the selected CLARIN license name, + * e.g. {@code /sections/clarin-license/name}. + */ + public static final String CLARIN_LICENSE_NAME_OPERATION_ENTRY = "name"; @Override - public DataLicense getData(SubmissionService submissionService, InProgressSubmission obj, - SubmissionStepConfig config) - throws Exception { - DataLicense result = new DataLicense(); - Bitstream bitstream = bitstreamService - .getBitstreamByName(obj.getItem(), Constants.LICENSE_BUNDLE_NAME, Constants.LICENSE_BITSTREAM_NAME); - if (bitstream != null) { - String acceptanceDate = bitstreamService.getMetadata(bitstream, DCTERMS_RIGHTSDATE); - result.setAcceptanceDate(acceptanceDate); - result.setUrl( - configurationService.getProperty("dspace.server.url") - + "/api/" + BitstreamRest.CATEGORY + "/" + English - .plural(BitstreamRest.NAME) + "/" + bitstream.getID() + "/content"); - result.setGranted(true); + public DataClarinLicense getData(SubmissionService submissionService, InProgressSubmission obj, + SubmissionStepConfig config) { + DataClarinLicense result = new DataClarinLicense(); + Item item = obj.getItem(); + if (item == null) { + return result; + } + + List name = itemService.getMetadataByMetadataString(item, "dc.rights"); + List uri = itemService.getMetadataByMetadataString(item, "dc.rights.uri"); + List label = itemService.getMetadataByMetadataString(item, "dc.rights.label"); + + if (CollectionUtils.isNotEmpty(name)) { + result.setName(name.get(0).getValue()); + } + if (CollectionUtils.isNotEmpty(uri)) { + result.setDefinition(uri.get(0).getValue()); + } + if (CollectionUtils.isNotEmpty(label)) { + result.setLabel(label.get(0).getValue()); } + result.setGranted(CollectionUtils.isNotEmpty(name) + && CollectionUtils.isNotEmpty(uri) + && CollectionUtils.isNotEmpty(label)); return result; } @@ -61,14 +81,48 @@ public DataLicense getData(SubmissionService submissionService, InProgressSubmis public void doPatchProcessing(Context context, HttpServletRequest currentRequest, InProgressSubmission source, Operation op, SubmissionStepConfig stepConf) throws Exception { - if (op.getPath().endsWith(LICENSE_STEP_OPERATION_ENTRY)) { + String path = op.getPath(); - PatchOperation patchOperation = new PatchOperationFactory() - .instanceOf(LICENSE_STEP_OPERATION_ENTRY, op.getOp()); - patchOperation.perform(context, currentRequest, source, op); + if (path.endsWith("/" + CLARIN_LICENSE_NAME_OPERATION_ENTRY) + || path.endsWith("/" + stepConf.getId())) { + String licenseName = extractLicenseName(op); + ClarinLicenseSubmissionUtils.applyLicense(context, source.getItem(), licenseName); + return; + } + + if (path.endsWith(LICENSE_STEP_OPERATION_ENTRY)) { + // The CLARIN license section is no longer backed by the deposit + // license bitstream, therefore a `granted` patch on this section + // is a no-op kept only for backward compatibility with older + // submission clients. + log.info("Ignoring legacy '{}/granted' patch on the CLARIN license section.", stepConf.getId()); + return; + } - } else { - throw new UnprocessableEntityException("The path " + op.getPath() + " cannot be patched"); + log.info("Ignoring unsupported patch path on CLARIN license section: {}", path); + } + + private String extractLicenseName(Operation op) { + Object value = op.getValue(); + if (value == null) { + return null; + } + if (value instanceof String) { + return (String) value; + } + if (value instanceof JsonValueEvaluator) { + JsonNode valueNode = ((JsonValueEvaluator) value).getValueNode(); + if (valueNode == null) { + return null; + } + JsonNode inner = valueNode.get("value"); + if (inner != null) { + return inner.asText(); + } + if (valueNode.isTextual()) { + return valueNode.asText(); + } } + return String.valueOf(value); } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java new file mode 100644 index 000000000000..e372cc4cb0c1 --- /dev/null +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java @@ -0,0 +1,123 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.app.rest.submit.step; + +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; + +import org.apache.commons.lang3.StringUtils; +import org.dspace.app.rest.exception.ClarinLicenseNotFoundException; +import org.dspace.authorize.AuthorizeException; +import org.dspace.content.Bitstream; +import org.dspace.content.Bundle; +import org.dspace.content.Item; +import org.dspace.content.clarin.ClarinLicense; +import org.dspace.content.factory.ClarinServiceFactory; +import org.dspace.content.factory.ContentServiceFactory; +import org.dspace.content.service.ItemService; +import org.dspace.content.service.clarin.ClarinLicenseResourceMappingService; +import org.dspace.content.service.clarin.ClarinLicenseService; +import org.dspace.core.Constants; +import org.dspace.core.Context; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Shared logic for applying a CLARIN license selection to an in-progress + * submission item. Used by both the top-level {@code /license} patch path + * in {@code WorkspaceItemRestRepository} (backward compatibility) and the + * section-based patch path handled by + * {@link ClarinLicenseResourceStep} (preferred contract introduced as part + * of the API duplicity fix between {@code sections.license} and + * {@code sections.clarin-license}). + * + * @author Milan Majchrak (milan.majchrak at dataquest.sk) + */ +public final class ClarinLicenseSubmissionUtils { + + private static final Logger log = LoggerFactory.getLogger(ClarinLicenseSubmissionUtils.class); + + private ClarinLicenseSubmissionUtils() { + } + + /** + * Apply the given CLARIN license selection to the supplied item. + *

+ * + * @param context DSpace context + * @param item the item being submitted + * @param clarinLicenseName name of the CLARIN license to apply, or + * {@code null}/empty to clear the current + * selection + * @throws SQLException on database errors + * @throws AuthorizeException on authorization errors + * @throws ClarinLicenseNotFoundException if a non-empty name was + * supplied but no matching CLARIN license exists + */ + public static void applyLicense(Context context, Item item, String clarinLicenseName) + throws SQLException, AuthorizeException { + if (Objects.isNull(item)) { + log.info("Cannot apply CLARIN license, item is null."); + return; + } + + ClarinLicenseService clarinLicenseService = + ClarinServiceFactory.getInstance().getClarinLicenseService(); + ClarinLicenseResourceMappingService clarinLicenseResourceMappingService = + ClarinServiceFactory.getInstance().getClarinLicenseResourceMappingService(); + ItemService itemService = ContentServiceFactory.getInstance().getItemService(); + + // Resolve license up-front so we fail before mutating state + ClarinLicense clarinLicense = null; + if (StringUtils.isNotBlank(clarinLicenseName)) { + clarinLicense = clarinLicenseService.findByName(context, clarinLicenseName); + if (Objects.isNull(clarinLicense)) { + throw new ClarinLicenseNotFoundException( + "The CLARIN license with name: " + clarinLicenseName + + " isn't supported in the CLARIN/DSpace"); + } + } + + // Clear existing license metadata from the item + clarinLicenseService.clearLicenseMetadataFromItem(context, item); + + // Detach existing CLARIN licenses from the uploaded bitstreams + List bundles = item.getBundles(Constants.CONTENT_BUNDLE_NAME); + for (Bundle bundle : bundles) { + for (Bitstream bitstream : bundle.getBitstreams()) { + clarinLicenseResourceMappingService.detachLicenses(context, bitstream); + } + } + + itemService.update(context, item); + + if (Objects.isNull(clarinLicense)) { + log.info("CLARIN license selection cleared on item {}.", item.getID()); + return; + } + + // Attach the new CLARIN license to every bitstream and add metadata + clarinLicenseService.addLicenseMetadataToItem(context, clarinLicense, item); + for (Bundle bundle : bundles) { + for (Bitstream bitstream : bundle.getBitstreams()) { + clarinLicenseResourceMappingService.attachLicense(context, clarinLicense, bitstream); + } + } + itemService.update(context, item); + } +} diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java index fdc406c5b67f..88f87563433d 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java @@ -820,6 +820,84 @@ public void updateClarinLicenseInWI() throws Exception { .andExpect(jsonPath("$.bitstreams", is(1))); } + /** + * Riesenie B: applying the CLARIN license via the section-scoped path + * `/sections/clarin-license/name` must work exactly like the legacy + * top-level `/license` path. + */ + @Test + public void addClarinLicenseViaSectionPatch() throws Exception { + context.turnOffAuthorisationSystem(); + WorkspaceItem witem = createWorkspaceItemWithFile(); + + String clarinLicenseName = "Test Section Clarin License"; + ClarinLicense clarinLicense = createClarinLicense(clarinLicenseName, "Test Def", "Test R Info", + Confirmation.NOT_REQUIRED); + context.restoreAuthSystemState(); + + List replaceOperations = new ArrayList(); + Map licenseReplaceOpValue = new HashMap(); + licenseReplaceOpValue.put("value", clarinLicenseName); + replaceOperations.add(new ReplaceOperation("/sections/clarin-license/name", + licenseReplaceOpValue)); + String updateBody = getPatchContent(replaceOperations); + + String tokenAdmin = getAuthToken(admin.getEmail(), password); + getClient(tokenAdmin).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(updateBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + + // Item metadata `dc.rights` must contain the CLARIN license name + assertClarinLicenseMetadata(witem, "dc", "rights", null, clarinLicenseName, false); + + // Bitstream must be attached to the CLARIN license + getClient(tokenAdmin).perform(get("/api/core/clarinlicenses/" + clarinLicense.getID())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.bitstreams", is(1))); + } + + /** + * Riesenie B: after applying a CLARIN license, GET on the workspace item + * must expose distinct payloads for the standard `license` section + * (CC license: url/acceptanceDate/granted) and the `clarin-license` + * section (name/definition/label/granted from `dc.rights*`). + */ + @Test + public void getWorkspaceItemReturnsDistinctLicenseSections() throws Exception { + context.turnOffAuthorisationSystem(); + WorkspaceItem witem = createWorkspaceItemWithFile(); + + String clarinLicenseName = "Distinct Sections Clarin License"; + createClarinLicense(clarinLicenseName, "Test Def", "Test R Info", + Confirmation.NOT_REQUIRED); + context.restoreAuthSystemState(); + + // Apply the CLARIN license through the new section-scoped path + List replaceOperations = new ArrayList(); + Map licenseReplaceOpValue = new HashMap(); + licenseReplaceOpValue.put("value", clarinLicenseName); + replaceOperations.add(new ReplaceOperation("/sections/clarin-license/name", + licenseReplaceOpValue)); + String updateBody = getPatchContent(replaceOperations); + + String tokenAdmin = getAuthToken(admin.getEmail(), password); + getClient(tokenAdmin).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(updateBody) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + + // GET the workspace item and assert the two sections are distinct + getClient(tokenAdmin).perform(get("/api/submission/workspaceitems/" + witem.getID())) + .andExpect(status().isOk()) + // clarin-license section reflects CLARIN-specific fields + .andExpect(jsonPath("$.sections['clarin-license'].name", is(clarinLicenseName))) + .andExpect(jsonPath("$.sections['clarin-license'].granted", is(true))) + // the standard CC license section must NOT be polluted with the + // CLARIN license name; it exposes its own (CC) shape with `url` + .andExpect(jsonPath("$.sections.license.name").doesNotExist()); + } + /** * Create Item with standard handle. The handle definition for every community is configured * by the `lr.pid.community.configurations` properties. From 1f9d3b63672a28edf51265a0268b12bff4b29613 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Thu, 21 May 2026 14:19:57 +0200 Subject: [PATCH 2/9] fix(clarin-license): rename step path to /select, rename DataClarinLicense to ClarinDataLicense, and clean up comments --- ...rinLicense.java => ClarinDataLicense.java} | 12 +++---- .../WorkspaceItemRestRepository.java | 5 ++- .../step/ClarinLicenseResourceStep.java | 33 +++++++------------ .../step/ClarinLicenseSubmissionUtils.java | 9 ++--- .../ClarinWorkspaceItemRestRepositoryIT.java | 14 ++++---- 5 files changed, 29 insertions(+), 44 deletions(-) rename dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/{DataClarinLicense.java => ClarinDataLicense.java} (79%) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/DataClarinLicense.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicense.java similarity index 79% rename from dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/DataClarinLicense.java rename to dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicense.java index bef5b3303a8f..d3cc7bd7d8b3 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/DataClarinLicense.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicense.java @@ -11,16 +11,14 @@ import com.fasterxml.jackson.annotation.JsonProperty.Access; /** - * Java Bean exposing the CLARIN license selected for an in-progress submission. - *

- * Unlike {@link DataLicense} which represents the distribution / deposit - * {@code LICENSE/license.txt} bitstream, this DTO represents the CLARIN - * resource license chosen by the user and stored in the item metadata - * ({@code dc.rights}, {@code dc.rights.uri}, {@code dc.rights.label}). + * DTO of the CLARIN resource license selected for an in-progress submission. + * Backed by the item metadata {@code dc.rights}, {@code dc.rights.uri} and + * {@code dc.rights.label}. Distinct from {@link DataLicense}, which represents + * the deposit {@code LICENSE/license.txt} bitstream. * * @author Milan Majchrak (milan.majchrak at dataquest.sk) */ -public class DataClarinLicense implements SectionData { +public class ClarinDataLicense implements SectionData { /** * Display name of the CLARIN license (value of {@code dc.rights}). diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java index bd7586a2d3e4..dff11e04ffe9 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java @@ -551,9 +551,8 @@ private void maintainLicensesForItem(Context context, WorkspaceItem source, Oper clarinLicenseName = jsonNodeValue.asText(); } - // Delegate to shared helper so the top-level `/license` path - // (legacy / backward-compat) and the new section patch path - // `/sections/clarin-license/name` apply the same logic. + // Delegate to the shared helper so the legacy `/license` path and the + // section path `/sections/clarin-license/select` apply the same logic. org.dspace.app.rest.submit.step.ClarinLicenseSubmissionUtils .applyLicense(context, item, clarinLicenseName); } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java index 3e56b2bd2561..a38c796e7f9c 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java @@ -14,7 +14,7 @@ import org.apache.commons.collections4.CollectionUtils; import org.dspace.app.rest.model.patch.JsonValueEvaluator; import org.dspace.app.rest.model.patch.Operation; -import org.dspace.app.rest.model.step.DataClarinLicense; +import org.dspace.app.rest.model.step.ClarinDataLicense; import org.dspace.app.rest.submit.AbstractProcessingStep; import org.dspace.app.rest.submit.SubmissionService; import org.dspace.app.util.SubmissionStepConfig; @@ -26,16 +26,10 @@ import org.slf4j.LoggerFactory; /** - * CLARIN license resource step for DSpace Spring REST. - *

- * Exposes the CLARIN license currently selected for the in-progress - * submission (sourced from the item's {@code dc.rights*} metadata) and - * accepts section-scoped patches under - * {@code /sections/clarin-license/name} to update that selection. - *

- * This step is intentionally not a clone of - * {@code LicenseStep} any more — the CLARIN license has nothing to do with - * the distribution license stored in {@code LICENSE/license.txt}. + * Submission step exposing the CLARIN resource license selected for the + * in-progress submission. Data is sourced from the item's {@code dc.rights*} + * metadata; the selection is updated via a section-scoped patch + * {@code /sections/clarin-license/select}. * * @author Milan Majchrak (milan.majchrak at dataquest.sk) */ @@ -44,15 +38,15 @@ public class ClarinLicenseResourceStep extends AbstractProcessingStep { private static final Logger log = LoggerFactory.getLogger(ClarinLicenseResourceStep.class); /** - * Section patch path entry used to set the selected CLARIN license name, - * e.g. {@code /sections/clarin-license/name}. + * Sub-path of the section patch used to select a CLARIN license by name, + * e.g. {@code /sections/clarin-license/select}. */ - public static final String CLARIN_LICENSE_NAME_OPERATION_ENTRY = "name"; + public static final String LICENSE_SELECT_OPERATION_ENTRY = "select"; @Override - public DataClarinLicense getData(SubmissionService submissionService, InProgressSubmission obj, + public ClarinDataLicense getData(SubmissionService submissionService, InProgressSubmission obj, SubmissionStepConfig config) { - DataClarinLicense result = new DataClarinLicense(); + ClarinDataLicense result = new ClarinDataLicense(); Item item = obj.getItem(); if (item == null) { return result; @@ -83,7 +77,7 @@ public void doPatchProcessing(Context context, HttpServletRequest currentRequest String path = op.getPath(); - if (path.endsWith("/" + CLARIN_LICENSE_NAME_OPERATION_ENTRY) + if (path.endsWith("/" + LICENSE_SELECT_OPERATION_ENTRY) || path.endsWith("/" + stepConf.getId())) { String licenseName = extractLicenseName(op); ClarinLicenseSubmissionUtils.applyLicense(context, source.getItem(), licenseName); @@ -91,10 +85,7 @@ public void doPatchProcessing(Context context, HttpServletRequest currentRequest } if (path.endsWith(LICENSE_STEP_OPERATION_ENTRY)) { - // The CLARIN license section is no longer backed by the deposit - // license bitstream, therefore a `granted` patch on this section - // is a no-op kept only for backward compatibility with older - // submission clients. + // `granted` patches are a no-op on this section; kept for older clients. log.info("Ignoring legacy '{}/granted' patch on the CLARIN license section.", stepConf.getId()); return; } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java index e372cc4cb0c1..c7133db67690 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java @@ -30,12 +30,9 @@ /** * Shared logic for applying a CLARIN license selection to an in-progress - * submission item. Used by both the top-level {@code /license} patch path - * in {@code WorkspaceItemRestRepository} (backward compatibility) and the - * section-based patch path handled by - * {@link ClarinLicenseResourceStep} (preferred contract introduced as part - * of the API duplicity fix between {@code sections.license} and - * {@code sections.clarin-license}). + * submission item. Used by both the legacy top-level {@code /license} patch + * path in {@code WorkspaceItemRestRepository} and the section-scoped patch + * path handled by {@link ClarinLicenseResourceStep}. * * @author Milan Majchrak (milan.majchrak at dataquest.sk) */ diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java index 88f87563433d..87e13066b45f 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java @@ -821,8 +821,8 @@ public void updateClarinLicenseInWI() throws Exception { } /** - * Riesenie B: applying the CLARIN license via the section-scoped path - * `/sections/clarin-license/name` must work exactly like the legacy + * Applying the CLARIN license via the section-scoped path + * `/sections/clarin-license/select` must work exactly like the legacy * top-level `/license` path. */ @Test @@ -838,7 +838,7 @@ public void addClarinLicenseViaSectionPatch() throws Exception { List replaceOperations = new ArrayList(); Map licenseReplaceOpValue = new HashMap(); licenseReplaceOpValue.put("value", clarinLicenseName); - replaceOperations.add(new ReplaceOperation("/sections/clarin-license/name", + replaceOperations.add(new ReplaceOperation("/sections/clarin-license/select", licenseReplaceOpValue)); String updateBody = getPatchContent(replaceOperations); @@ -858,8 +858,8 @@ public void addClarinLicenseViaSectionPatch() throws Exception { } /** - * Riesenie B: after applying a CLARIN license, GET on the workspace item - * must expose distinct payloads for the standard `license` section + * After applying a CLARIN license, GET on the workspace item must expose + * distinct payloads for the standard `license` section * (CC license: url/acceptanceDate/granted) and the `clarin-license` * section (name/definition/label/granted from `dc.rights*`). */ @@ -873,11 +873,11 @@ public void getWorkspaceItemReturnsDistinctLicenseSections() throws Exception { Confirmation.NOT_REQUIRED); context.restoreAuthSystemState(); - // Apply the CLARIN license through the new section-scoped path + // Apply the CLARIN license through the section-scoped path List replaceOperations = new ArrayList(); Map licenseReplaceOpValue = new HashMap(); licenseReplaceOpValue.put("value", clarinLicenseName); - replaceOperations.add(new ReplaceOperation("/sections/clarin-license/name", + replaceOperations.add(new ReplaceOperation("/sections/clarin-license/select", licenseReplaceOpValue)); String updateBody = getPatchContent(replaceOperations); From a45ce82e033f4a212d8ff24168d5527ca0371032 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Fri, 22 May 2026 09:28:28 +0200 Subject: [PATCH 3/9] fix(clarin-license): align DTO name with Rest suffix convention --- ...icense.java => ClarinDataLicenseRest.java} | 2 +- .../step/ClarinLicenseResourceStep.java | 15 +- .../ClarinWorkspaceItemRestRepositoryIT.java | 142 ++++++++++++++++++ 3 files changed, 154 insertions(+), 5 deletions(-) rename dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/{ClarinDataLicense.java => ClarinDataLicenseRest.java} (97%) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicense.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicenseRest.java similarity index 97% rename from dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicense.java rename to dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicenseRest.java index d3cc7bd7d8b3..b07df532c0f2 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicense.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicenseRest.java @@ -18,7 +18,7 @@ * * @author Milan Majchrak (milan.majchrak at dataquest.sk) */ -public class ClarinDataLicense implements SectionData { +public class ClarinDataLicenseRest implements SectionData { /** * Display name of the CLARIN license (value of {@code dc.rights}). diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java index a38c796e7f9c..2a3abe146cc7 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java @@ -12,9 +12,11 @@ import com.fasterxml.jackson.databind.JsonNode; import org.apache.commons.collections4.CollectionUtils; +import org.dspace.app.rest.exception.ClarinLicenseNotFoundException; +import org.dspace.app.rest.exception.UnprocessableEntityException; import org.dspace.app.rest.model.patch.JsonValueEvaluator; import org.dspace.app.rest.model.patch.Operation; -import org.dspace.app.rest.model.step.ClarinDataLicense; +import org.dspace.app.rest.model.step.ClarinDataLicenseRest; import org.dspace.app.rest.submit.AbstractProcessingStep; import org.dspace.app.rest.submit.SubmissionService; import org.dspace.app.util.SubmissionStepConfig; @@ -44,9 +46,9 @@ public class ClarinLicenseResourceStep extends AbstractProcessingStep { public static final String LICENSE_SELECT_OPERATION_ENTRY = "select"; @Override - public ClarinDataLicense getData(SubmissionService submissionService, InProgressSubmission obj, + public ClarinDataLicenseRest getData(SubmissionService submissionService, InProgressSubmission obj, SubmissionStepConfig config) { - ClarinDataLicense result = new ClarinDataLicense(); + ClarinDataLicenseRest result = new ClarinDataLicenseRest(); Item item = obj.getItem(); if (item == null) { return result; @@ -80,7 +82,12 @@ public void doPatchProcessing(Context context, HttpServletRequest currentRequest if (path.endsWith("/" + LICENSE_SELECT_OPERATION_ENTRY) || path.endsWith("/" + stepConf.getId())) { String licenseName = extractLicenseName(op); - ClarinLicenseSubmissionUtils.applyLicense(context, source.getItem(), licenseName); + try { + ClarinLicenseSubmissionUtils.applyLicense(context, source.getItem(), licenseName); + } catch (ClarinLicenseNotFoundException ex) { + // Surface invalid client input as 422 instead of leaking as 500. + throw new UnprocessableEntityException(ex.getMessage(), ex); + } return; } diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java index 87e13066b45f..9babdaee6263 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java @@ -898,6 +898,148 @@ public void getWorkspaceItemReturnsDistinctLicenseSections() throws Exception { .andExpect(jsonPath("$.sections.license.name").doesNotExist()); } + /** + * PATCH on `/sections/clarin-license/select` with an empty value must clear + * the previously selected license: `dc.rights*` metadata is removed and the + * license is detached from the uploaded bitstream. + */ + @Test + public void patchSelectWithEmptyValueClearsLicense() throws Exception { + context.turnOffAuthorisationSystem(); + WorkspaceItem witem = createWorkspaceItemWithFile(); + + String clarinLicenseName = "Empty Value Clarin License"; + ClarinLicense clarinLicense = createClarinLicense(clarinLicenseName, "Test Def", "Test R Info", + Confirmation.NOT_REQUIRED); + context.restoreAuthSystemState(); + + String tokenAdmin = getAuthToken(admin.getEmail(), password); + + // First select the license through the new section path + List ops = new ArrayList(); + Map selectValue = new HashMap(); + selectValue.put("value", clarinLicenseName); + ops.add(new ReplaceOperation("/sections/clarin-license/select", selectValue)); + getClient(tokenAdmin).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(getPatchContent(ops)) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + assertClarinLicenseMetadata(witem, "dc", "rights", null, clarinLicenseName, false); + getClient(tokenAdmin).perform(get("/api/core/clarinlicenses/" + clarinLicense.getID())) + .andExpect(jsonPath("$.bitstreams", is(1))); + + // Now clear the selection with an empty value + ops.clear(); + Map emptyValue = new HashMap(); + emptyValue.put("value", ""); + ops.add(new ReplaceOperation("/sections/clarin-license/select", emptyValue)); + getClient(tokenAdmin).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(getPatchContent(ops)) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + + // Item metadata cleared and license detached from bitstream + assertClarinLicenseMetadata(witem, "dc", "rights", null, null, true); + getClient(tokenAdmin).perform(get("/api/core/clarinlicenses/" + clarinLicense.getID())) + .andExpect(jsonPath("$.bitstreams", is(0))); + } + + /** + * Two consecutive PATCHes on `/sections/clarin-license/select` must replace + * the previously selected license: `dc.rights` reflects the latest name and + * the bitstream is moved from the first license to the second one. + */ + @Test + public void patchSelectReplacesPreviousLicense() throws Exception { + context.turnOffAuthorisationSystem(); + WorkspaceItem witem = createWorkspaceItemWithFile(); + + String firstName = "First Clarin License"; + String secondName = "Second Clarin License"; + ClarinLicense first = createClarinLicense(firstName, "Def1", "Info1", Confirmation.NOT_REQUIRED); + ClarinLicense second = createClarinLicense(secondName, "Def2", "Info2", Confirmation.NOT_REQUIRED); + context.restoreAuthSystemState(); + + String tokenAdmin = getAuthToken(admin.getEmail(), password); + + // Select first license + List ops = new ArrayList(); + Map v1 = new HashMap(); + v1.put("value", firstName); + ops.add(new ReplaceOperation("/sections/clarin-license/select", v1)); + getClient(tokenAdmin).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(getPatchContent(ops)) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + assertClarinLicenseMetadata(witem, "dc", "rights", null, firstName, false); + getClient(tokenAdmin).perform(get("/api/core/clarinlicenses/" + first.getID())) + .andExpect(jsonPath("$.bitstreams", is(1))); + + // Replace with second license through the same section path + ops.clear(); + Map v2 = new HashMap(); + v2.put("value", secondName); + ops.add(new ReplaceOperation("/sections/clarin-license/select", v2)); + getClient(tokenAdmin).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(getPatchContent(ops)) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isOk()); + + assertClarinLicenseMetadata(witem, "dc", "rights", null, secondName, false); + getClient(tokenAdmin).perform(get("/api/core/clarinlicenses/" + first.getID())) + .andExpect(jsonPath("$.bitstreams", is(0))); + getClient(tokenAdmin).perform(get("/api/core/clarinlicenses/" + second.getID())) + .andExpect(jsonPath("$.bitstreams", is(1))); + } + + /** + * PATCH on `/sections/clarin-license/select` with a name that does not + * resolve to an existing CLARIN license must fail with 422 and must not + * mutate the item's `dc.rights*` metadata. + */ + @Test + public void patchSelectWithUnknownLicenseNameFails() throws Exception { + context.turnOffAuthorisationSystem(); + WorkspaceItem witem = createWorkspaceItemWithFile(); + context.restoreAuthSystemState(); + + List ops = new ArrayList(); + Map v = new HashMap(); + v.put("value", "Definitely Not An Existing License"); + ops.add(new ReplaceOperation("/sections/clarin-license/select", v)); + + String tokenAdmin = getAuthToken(admin.getEmail(), password); + getClient(tokenAdmin).perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(getPatchContent(ops)) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isUnprocessableEntity()); + // The 422 response guarantees no mutation: applyLicense resolves the + // license up-front and throws before clearing/updating any metadata. + } + + /** + * Anonymous PATCH on `/sections/clarin-license/select` must be rejected + * (the user is not authenticated to modify the submission). + */ + @Test + public void patchSelectAsAnonymousIsUnauthorized() throws Exception { + context.turnOffAuthorisationSystem(); + WorkspaceItem witem = createWorkspaceItemWithFile(); + String clarinLicenseName = "Anon Clarin License"; + createClarinLicense(clarinLicenseName, "Def", "Info", Confirmation.NOT_REQUIRED); + context.restoreAuthSystemState(); + + List ops = new ArrayList(); + Map v = new HashMap(); + v.put("value", clarinLicenseName); + ops.add(new ReplaceOperation("/sections/clarin-license/select", v)); + + getClient().perform(patch("/api/submission/workspaceitems/" + witem.getID()) + .content(getPatchContent(ops)) + .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) + .andExpect(status().isUnauthorized()); + } + /** * Create Item with standard handle. The handle definition for every community is configured * by the `lr.pid.community.configurations` properties. From 932b1af9542b29dc347023bb187f0e46bdc891f9 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 25 May 2026 11:55:01 +0200 Subject: [PATCH 4/9] fix(submission): align CLARIN section DTO naming and apply Copilot review fixes --- ...icenseRest.java => ClarinDataLicense.java} | 23 +++---------------- .../WorkspaceItemRestRepository.java | 4 ++-- .../step/ClarinLicenseResourceStep.java | 12 ++++++---- .../ClarinWorkspaceItemRestRepositoryIT.java | 11 +++++---- 4 files changed, 19 insertions(+), 31 deletions(-) rename dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/{ClarinDataLicenseRest.java => ClarinDataLicense.java} (59%) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicenseRest.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicense.java similarity index 59% rename from dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicenseRest.java rename to dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicense.java index b07df532c0f2..a6bdfa803a96 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicenseRest.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/model/step/ClarinDataLicense.java @@ -11,37 +11,20 @@ import com.fasterxml.jackson.annotation.JsonProperty.Access; /** - * DTO of the CLARIN resource license selected for an in-progress submission. - * Backed by the item metadata {@code dc.rights}, {@code dc.rights.uri} and - * {@code dc.rights.label}. Distinct from {@link DataLicense}, which represents - * the deposit {@code LICENSE/license.txt} bitstream. + * Java Bean to expose the CLARIN license section during in progress submission. * * @author Milan Majchrak (milan.majchrak at dataquest.sk) */ -public class ClarinDataLicenseRest implements SectionData { +public class ClarinDataLicense implements SectionData { - /** - * Display name of the CLARIN license (value of {@code dc.rights}). - */ private String name; - /** - * URI / definition of the CLARIN license (value of {@code dc.rights.uri}). - */ @JsonProperty(access = Access.READ_ONLY) private String definition; - /** - * Short label of the CLARIN license (value of {@code dc.rights.label}). - */ @JsonProperty(access = Access.READ_ONLY) private String label; - /** - * Whether the CLARIN license is granted, i.e. all three metadata fields - * ({@code dc.rights}, {@code dc.rights.uri}, {@code dc.rights.label}) are - * present on the item. - */ private boolean granted = false; public String getName() { @@ -75,4 +58,4 @@ public boolean isGranted() { public void setGranted(boolean granted) { this.granted = granted; } -} +} \ No newline at end of file diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java index dff11e04ffe9..4ee4de1d21fd 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java @@ -42,6 +42,7 @@ import org.dspace.app.rest.model.patch.ReplaceOperation; import org.dspace.app.rest.repository.handler.service.UriListHandlerService; import org.dspace.app.rest.submit.SubmissionService; +import org.dspace.app.rest.submit.step.ClarinLicenseSubmissionUtils; import org.dspace.app.rest.submit.UploadableStep; import org.dspace.app.rest.utils.BigMultipartFile; import org.dspace.app.rest.utils.Utils; @@ -553,8 +554,7 @@ private void maintainLicensesForItem(Context context, WorkspaceItem source, Oper // Delegate to the shared helper so the legacy `/license` path and the // section path `/sections/clarin-license/select` apply the same logic. - org.dspace.app.rest.submit.step.ClarinLicenseSubmissionUtils - .applyLicense(context, item, clarinLicenseName); + ClarinLicenseSubmissionUtils.applyLicense(context, item, clarinLicenseName); } private void grantDistributionLicense(Context context, WorkspaceItem source, Operation op) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java index 2a3abe146cc7..eeb31ee14ca3 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java @@ -16,7 +16,7 @@ import org.dspace.app.rest.exception.UnprocessableEntityException; import org.dspace.app.rest.model.patch.JsonValueEvaluator; import org.dspace.app.rest.model.patch.Operation; -import org.dspace.app.rest.model.step.ClarinDataLicenseRest; +import org.dspace.app.rest.model.step.ClarinDataLicense; import org.dspace.app.rest.submit.AbstractProcessingStep; import org.dspace.app.rest.submit.SubmissionService; import org.dspace.app.util.SubmissionStepConfig; @@ -46,9 +46,9 @@ public class ClarinLicenseResourceStep extends AbstractProcessingStep { public static final String LICENSE_SELECT_OPERATION_ENTRY = "select"; @Override - public ClarinDataLicenseRest getData(SubmissionService submissionService, InProgressSubmission obj, + public ClarinDataLicense getData(SubmissionService submissionService, InProgressSubmission obj, SubmissionStepConfig config) { - ClarinDataLicenseRest result = new ClarinDataLicenseRest(); + ClarinDataLicense result = new ClarinDataLicense(); Item item = obj.getItem(); if (item == null) { return result; @@ -81,6 +81,10 @@ public void doPatchProcessing(Context context, HttpServletRequest currentRequest if (path.endsWith("/" + LICENSE_SELECT_OPERATION_ENTRY) || path.endsWith("/" + stepConf.getId())) { + if (!"replace".equals(op.getOp())) { + throw new UnprocessableEntityException( + "The operation '" + op.getOp() + "' is not supported for path " + path); + } String licenseName = extractLicenseName(op); try { ClarinLicenseSubmissionUtils.applyLicense(context, source.getItem(), licenseName); @@ -97,7 +101,7 @@ public void doPatchProcessing(Context context, HttpServletRequest currentRequest return; } - log.info("Ignoring unsupported patch path on CLARIN license section: {}", path); + throw new UnprocessableEntityException("The path " + path + " cannot be patched"); } private String extractLicenseName(Operation op) { diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java index 9babdaee6263..a827a9700afb 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java @@ -892,10 +892,12 @@ public void getWorkspaceItemReturnsDistinctLicenseSections() throws Exception { .andExpect(status().isOk()) // clarin-license section reflects CLARIN-specific fields .andExpect(jsonPath("$.sections['clarin-license'].name", is(clarinLicenseName))) + .andExpect(jsonPath("$.sections['clarin-license'].definition").isNotEmpty()) + .andExpect(jsonPath("$.sections['clarin-license'].label").isNotEmpty()) .andExpect(jsonPath("$.sections['clarin-license'].granted", is(true))) - // the standard CC license section must NOT be polluted with the - // CLARIN license name; it exposes its own (CC) shape with `url` - .andExpect(jsonPath("$.sections.license.name").doesNotExist()); + .andExpect(jsonPath("$.sections.license.granted", is(false))) + .andExpect(jsonPath("$.sections.license.acceptanceDate").isEmpty()) + .andExpect(jsonPath("$.sections.license.url").isEmpty()); } /** @@ -1013,8 +1015,7 @@ public void patchSelectWithUnknownLicenseNameFails() throws Exception { .content(getPatchContent(ops)) .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) .andExpect(status().isUnprocessableEntity()); - // The 422 response guarantees no mutation: applyLicense resolves the - // license up-front and throws before clearing/updating any metadata. + assertClarinLicenseMetadata(witem, "dc", "rights", null, null, true); } /** From 480bac04a90b42543c46264a0ab969d3bb53eade Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 25 May 2026 12:15:13 +0200 Subject: [PATCH 5/9] Align CLARIN license patch semantics and tighten section path handling --- .../app/rest/repository/WorkspaceItemRestRepository.java | 7 ++++++- .../app/rest/submit/step/ClarinLicenseResourceStep.java | 3 +-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java index 4ee4de1d21fd..93ace045cb20 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java @@ -63,6 +63,7 @@ import org.dspace.content.service.WorkspaceItemService; import org.dspace.content.service.clarin.ClarinLicenseResourceMappingService; import org.dspace.content.service.clarin.ClarinLicenseService; +import org.dspace.app.rest.exception.ClarinLicenseNotFoundException; import org.dspace.core.Constants; import org.dspace.core.Context; import org.dspace.eperson.EPerson; @@ -554,7 +555,11 @@ private void maintainLicensesForItem(Context context, WorkspaceItem source, Oper // Delegate to the shared helper so the legacy `/license` path and the // section path `/sections/clarin-license/select` apply the same logic. - ClarinLicenseSubmissionUtils.applyLicense(context, item, clarinLicenseName); + try { + ClarinLicenseSubmissionUtils.applyLicense(context, item, clarinLicenseName); + } catch (ClarinLicenseNotFoundException ex) { + throw new UnprocessableEntityException(ex.getMessage(), ex); + } } private void grantDistributionLicense(Context context, WorkspaceItem source, Operation op) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java index eeb31ee14ca3..d03d9adc65ba 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java @@ -79,8 +79,7 @@ public void doPatchProcessing(Context context, HttpServletRequest currentRequest String path = op.getPath(); - if (path.endsWith("/" + LICENSE_SELECT_OPERATION_ENTRY) - || path.endsWith("/" + stepConf.getId())) { + if (path.endsWith("/" + LICENSE_SELECT_OPERATION_ENTRY)) { if (!"replace".equals(op.getOp())) { throw new UnprocessableEntityException( "The operation '" + op.getOp() + "' is not supported for path " + path); From 66e87967735a971b16d15dc3d043a9bd0ec291cc Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 25 May 2026 12:59:25 +0200 Subject: [PATCH 6/9] Fix checkstyle issue --- .../app/rest/repository/WorkspaceItemRestRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java index 93ace045cb20..001e6794c5a7 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java @@ -31,6 +31,7 @@ import org.dspace.app.rest.Parameter; import org.dspace.app.rest.SearchRestMethod; import org.dspace.app.rest.converter.WorkspaceItemConverter; +import org.dspace.app.rest.exception.ClarinLicenseNotFoundException; import org.dspace.app.rest.exception.DSpaceBadRequestException; import org.dspace.app.rest.exception.RepositoryMethodNotImplementedException; import org.dspace.app.rest.exception.UnprocessableEntityException; @@ -42,8 +43,8 @@ import org.dspace.app.rest.model.patch.ReplaceOperation; import org.dspace.app.rest.repository.handler.service.UriListHandlerService; import org.dspace.app.rest.submit.SubmissionService; -import org.dspace.app.rest.submit.step.ClarinLicenseSubmissionUtils; import org.dspace.app.rest.submit.UploadableStep; +import org.dspace.app.rest.submit.step.ClarinLicenseSubmissionUtils; import org.dspace.app.rest.utils.BigMultipartFile; import org.dspace.app.rest.utils.Utils; import org.dspace.app.util.SubmissionConfig; @@ -63,7 +64,6 @@ import org.dspace.content.service.WorkspaceItemService; import org.dspace.content.service.clarin.ClarinLicenseResourceMappingService; import org.dspace.content.service.clarin.ClarinLicenseService; -import org.dspace.app.rest.exception.ClarinLicenseNotFoundException; import org.dspace.core.Constants; import org.dspace.core.Context; import org.dspace.eperson.EPerson; From 30e707b272e5a5ea5ba6bfcfbaffd76eae259f66 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Mon, 25 May 2026 13:58:02 +0200 Subject: [PATCH 7/9] Stabilize unknown CLARIN license metadata assertion --- .../ClarinWorkspaceItemRestRepositoryIT.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java index a827a9700afb..aae481283540 100644 --- a/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java +++ b/dspace-server-webapp/src/test/java/org/dspace/app/rest/ClarinWorkspaceItemRestRepositoryIT.java @@ -1003,6 +1003,12 @@ public void patchSelectReplacesPreviousLicense() throws Exception { public void patchSelectWithUnknownLicenseNameFails() throws Exception { context.turnOffAuthorisationSystem(); WorkspaceItem witem = createWorkspaceItemWithFile(); + List rightsBefore = itemService.getMetadata(witem.getItem(), "dc", "rights", null, + null, Item.ANY); + List rightsBeforeValues = new ArrayList(); + for (MetadataValue metadataValue : rightsBefore) { + rightsBeforeValues.add(metadataValue.getValue()); + } context.restoreAuthSystemState(); List ops = new ArrayList(); @@ -1015,7 +1021,15 @@ public void patchSelectWithUnknownLicenseNameFails() throws Exception { .content(getPatchContent(ops)) .contentType(MediaType.APPLICATION_JSON_PATCH_JSON)) .andExpect(status().isUnprocessableEntity()); - assertClarinLicenseMetadata(witem, "dc", "rights", null, null, true); + + witem = context.reloadEntity(witem); + List rightsAfter = itemService.getMetadata(witem.getItem(), "dc", "rights", null, + null, Item.ANY); + List rightsAfterValues = new ArrayList(); + for (MetadataValue metadataValue : rightsAfter) { + rightsAfterValues.add(metadataValue.getValue()); + } + Assert.assertEquals(rightsBeforeValues, rightsAfterValues); } /** From 1382ae373972b6d8646ddb9c9614cb2d3c9bcadb Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Tue, 26 May 2026 14:02:02 +0200 Subject: [PATCH 8/9] Added doc and checked null value --- .../step/ClarinLicenseResourceStep.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java index d03d9adc65ba..e6b50213df7d 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java @@ -85,6 +85,12 @@ public void doPatchProcessing(Context context, HttpServletRequest currentRequest "The operation '" + op.getOp() + "' is not supported for path " + path); } String licenseName = extractLicenseName(op); + if (licenseName == null || licenseName.isEmpty()) { + // No usable license name in the patch payload -> reject as client error + // instead of calling the service with a null/empty value. + throw new UnprocessableEntityException( + "The patch value for path " + path + " must contain a non-empty license name."); + } try { ClarinLicenseSubmissionUtils.applyLicense(context, source.getItem(), licenseName); } catch (ClarinLicenseNotFoundException ex) { @@ -103,6 +109,30 @@ public void doPatchProcessing(Context context, HttpServletRequest currentRequest throw new UnprocessableEntityException("The path " + path + " cannot be patched"); } + /** + * Extract the CLARIN license name from a JSON Patch {@link Operation}. + *

+ * The submission API receives section updates as JSON Patch operations + * (see {@code /sections/clarin-license/select}). The {@code value} field + * of such an operation is not strongly typed: depending on the request + * shape and how the JSON Patch payload was parsed upstream, it can arrive + * as: + *

    + *
  • a plain {@link String}, e.g. {@code "value": "CC-BY"};
  • + *
  • a {@link JsonValueEvaluator} wrapping a {@link JsonNode}, when the + * payload is sent as a JSON object such as + * {@code "value": { "value": "CC-BY" }} or as a bare textual node;
  • + *
  • {@code null} when the client omitted the value entirely.
  • + *
+ * This helper normalizes those cases into a single {@code String} license + * name (or {@code null} if no usable value is present), so the rest of the + * step can call {@link ClarinLicenseSubmissionUtils#applyLicense} with a + * simple value and treat missing input as a client error. + * + * @param op the JSON Patch operation targeting the license {@code select} path + * @return the license name extracted from the operation value, or {@code null} + * if the operation has no usable value + */ private String extractLicenseName(Operation op) { Object value = op.getValue(); if (value == null) { From e7216a8a0aae8d73b88bc883fe913a4ff0ff111d Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Tue, 26 May 2026 14:34:42 +0200 Subject: [PATCH 9/9] Harden CLARIN license patch handling and logging --- .../WorkspaceItemRestRepository.java | 5 +++-- .../step/ClarinLicenseResourceStep.java | 21 ++++++++++++------- .../step/ClarinLicenseSubmissionUtils.java | 6 ++++-- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java index 001e6794c5a7..b4f29213af85 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java @@ -530,12 +530,13 @@ private void maintainLicensesForItem(Context context, WorkspaceItem source, Oper // Get item Item item = source.getItem(); if (Objects.isNull(item)) { - // add log + log.warn("Cannot maintain CLARIN licenses: workspace item {} has no underlying item.", source.getID()); return; } // Get value from operation if (!(op instanceof ReplaceOperation)) { - // add log + log.warn("Ignoring non-replace operation '{}' on license patch path for workspace item {}.", + op.getOp(), source.getID()); return; } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java index e6b50213df7d..b3e315af445e 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java @@ -12,10 +12,12 @@ import com.fasterxml.jackson.databind.JsonNode; import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; import org.dspace.app.rest.exception.ClarinLicenseNotFoundException; import org.dspace.app.rest.exception.UnprocessableEntityException; import org.dspace.app.rest.model.patch.JsonValueEvaluator; import org.dspace.app.rest.model.patch.Operation; +import org.dspace.app.rest.model.patch.ReplaceOperation; import org.dspace.app.rest.model.step.ClarinDataLicense; import org.dspace.app.rest.submit.AbstractProcessingStep; import org.dspace.app.rest.submit.SubmissionService; @@ -43,7 +45,7 @@ public class ClarinLicenseResourceStep extends AbstractProcessingStep { * Sub-path of the section patch used to select a CLARIN license by name, * e.g. {@code /sections/clarin-license/select}. */ - public static final String LICENSE_SELECT_OPERATION_ENTRY = "select"; + private static final String LICENSE_SELECT_OPERATION_ENTRY = "select"; @Override public ClarinDataLicense getData(SubmissionService submissionService, InProgressSubmission obj, @@ -80,14 +82,16 @@ public void doPatchProcessing(Context context, HttpServletRequest currentRequest String path = op.getPath(); if (path.endsWith("/" + LICENSE_SELECT_OPERATION_ENTRY)) { - if (!"replace".equals(op.getOp())) { + if (!(op instanceof ReplaceOperation)) { throw new UnprocessableEntityException( "The operation '" + op.getOp() + "' is not supported for path " + path); } String licenseName = extractLicenseName(op); - if (licenseName == null || licenseName.isEmpty()) { - // No usable license name in the patch payload -> reject as client error - // instead of calling the service with a null/empty value. + // Section endpoint: a missing or blank license name is treated as a + // client error (422). The legacy `/license` path in + // WorkspaceItemRestRepository intentionally treats a blank value as + // "clear the current license" for backwards compatibility. + if (StringUtils.isBlank(licenseName)) { throw new UnprocessableEntityException( "The patch value for path " + path + " must contain a non-empty license name."); } @@ -131,7 +135,8 @@ public void doPatchProcessing(Context context, HttpServletRequest currentRequest * * @param op the JSON Patch operation targeting the license {@code select} path * @return the license name extracted from the operation value, or {@code null} - * if the operation has no usable value + * if the operation has no usable value (missing, null, or of an + * unsupported type) */ private String extractLicenseName(Operation op) { Object value = op.getValue(); @@ -154,6 +159,8 @@ private String extractLicenseName(Operation op) { return valueNode.asText(); } } - return String.valueOf(value); + log.warn("Unsupported Operation value type for license name extraction: {}", + value.getClass().getName()); + return null; } } diff --git a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java index c7133db67690..ccc6e886408f 100644 --- a/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java +++ b/dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseSubmissionUtils.java @@ -101,9 +101,9 @@ public static void applyLicense(Context context, Item item, String clarinLicense } } - itemService.update(context, item); - if (Objects.isNull(clarinLicense)) { + // Persist the cleared metadata state and stop. + itemService.update(context, item); log.info("CLARIN license selection cleared on item {}.", item.getID()); return; } @@ -115,6 +115,8 @@ public static void applyLicense(Context context, Item item, String clarinLicense clarinLicenseResourceMappingService.attachLicense(context, clarinLicense, bitstream); } } + // Persist all metadata changes in a single update at the end. itemService.update(context, item); + log.info("CLARIN license '{}' applied to item {}.", clarinLicenseName, item.getID()); } }