Skip to content

Commit d0c04cb

Browse files
UFAL/Separate CLARIN license payload from sections.license (#1319)
* fix(submission): separate CLARIN license payload from sections.license * fix(clarin-license): rename step path to /select, rename DataClarinLicense to ClarinDataLicense, and clean up comments * fix(clarin-license): align DTO name with Rest suffix convention * fix(submission): align CLARIN section DTO naming and apply Copilot review fixes * Align CLARIN license patch semantics and tighten section path handling * Fix checkstyle issue * Stabilize unknown CLARIN license metadata assertion * Added doc and checked null value * Harden CLARIN license patch handling and logging
1 parent 74957d8 commit d0c04cb

5 files changed

Lines changed: 554 additions & 85 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* The contents of this file are subject to the license and copyright
3+
* detailed in the LICENSE and NOTICE files at the root of the source
4+
* tree and available online at
5+
*
6+
* http://www.dspace.org/license/
7+
*/
8+
package org.dspace.app.rest.model.step;
9+
10+
import com.fasterxml.jackson.annotation.JsonProperty;
11+
import com.fasterxml.jackson.annotation.JsonProperty.Access;
12+
13+
/**
14+
* Java Bean to expose the CLARIN license section during in progress submission.
15+
*
16+
* @author Milan Majchrak (milan.majchrak at dataquest.sk)
17+
*/
18+
public class ClarinDataLicense implements SectionData {
19+
20+
private String name;
21+
22+
@JsonProperty(access = Access.READ_ONLY)
23+
private String definition;
24+
25+
@JsonProperty(access = Access.READ_ONLY)
26+
private String label;
27+
28+
private boolean granted = false;
29+
30+
public String getName() {
31+
return name;
32+
}
33+
34+
public void setName(String name) {
35+
this.name = name;
36+
}
37+
38+
public String getDefinition() {
39+
return definition;
40+
}
41+
42+
public void setDefinition(String definition) {
43+
this.definition = definition;
44+
}
45+
46+
public String getLabel() {
47+
return label;
48+
}
49+
50+
public void setLabel(String label) {
51+
this.label = label;
52+
}
53+
54+
public boolean isGranted() {
55+
return granted;
56+
}
57+
58+
public void setGranted(boolean granted) {
59+
this.granted = granted;
60+
}
61+
}

dspace-server-webapp/src/main/java/org/dspace/app/rest/repository/WorkspaceItemRestRepository.java

Lines changed: 10 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,19 @@
4444
import org.dspace.app.rest.repository.handler.service.UriListHandlerService;
4545
import org.dspace.app.rest.submit.SubmissionService;
4646
import org.dspace.app.rest.submit.UploadableStep;
47+
import org.dspace.app.rest.submit.step.ClarinLicenseSubmissionUtils;
4748
import org.dspace.app.rest.utils.BigMultipartFile;
4849
import org.dspace.app.rest.utils.Utils;
4950
import org.dspace.app.util.SubmissionConfig;
5051
import org.dspace.app.util.SubmissionConfigReaderException;
5152
import org.dspace.app.util.SubmissionStepConfig;
5253
import org.dspace.authorize.AuthorizeException;
5354
import org.dspace.authorize.service.AuthorizeService;
54-
import org.dspace.content.Bitstream;
55-
import org.dspace.content.Bundle;
5655
import org.dspace.content.Collection;
5756
import org.dspace.content.Item;
5857
import org.dspace.content.LicenseUtils;
5958
import org.dspace.content.MetadataValue;
6059
import org.dspace.content.WorkspaceItem;
61-
import org.dspace.content.clarin.ClarinLicense;
6260
import org.dspace.content.service.BitstreamFormatService;
6361
import org.dspace.content.service.BitstreamService;
6462
import org.dspace.content.service.CollectionService;
@@ -532,12 +530,13 @@ private void maintainLicensesForItem(Context context, WorkspaceItem source, Oper
532530
// Get item
533531
Item item = source.getItem();
534532
if (Objects.isNull(item)) {
535-
// add log
533+
log.warn("Cannot maintain CLARIN licenses: workspace item {} has no underlying item.", source.getID());
536534
return;
537535
}
538536
// Get value from operation
539537
if (!(op instanceof ReplaceOperation)) {
540-
// add log
538+
log.warn("Ignoring non-replace operation '{}' on license patch path for workspace item {}.",
539+
op.getOp(), source.getID());
541540
return;
542541
}
543542

@@ -555,53 +554,13 @@ private void maintainLicensesForItem(Context context, WorkspaceItem source, Oper
555554
clarinLicenseName = jsonNodeValue.asText();
556555
}
557556

558-
// Get clarin license by definition
559-
ClarinLicense clarinLicense = clarinLicenseService.findByName(context, clarinLicenseName);
560-
if (StringUtils.isNotBlank(clarinLicenseName) && Objects.isNull(clarinLicense)) {
561-
throw new ClarinLicenseNotFoundException("Cannot patch workspace item with id: " + source.getID() + "," +
562-
" because the clarin license with name: " + clarinLicenseName + " isn't supported in" +
563-
" the CLARIN/DSpace");
564-
}
565-
566-
// Clear the license metadata from the item
567-
clarinLicenseService.clearLicenseMetadataFromItem(context, item);
568-
569-
// Detach the clarin licenses from the uploaded bitstreams
570-
List<Bundle> bundles = item.getBundles(Constants.CONTENT_BUNDLE_NAME);
571-
for (Bundle bundle : bundles) {
572-
List<Bitstream> bitstreamList = bundle.getBitstreams();
573-
for (Bitstream bitstream : bitstreamList) {
574-
// in case bitstream ID exists in license table for some reason .. just remove it
575-
this.clarinLicenseResourceMappingService.detachLicenses(context, bitstream);
576-
}
577-
}
578-
579-
// Save changes to database
580-
itemService.update(context, item);
581-
582-
if (Objects.isNull(clarinLicense)) {
583-
log.info("The clarin license is null so all item metadata for license was cleared and the" +
584-
"licenses was detached.");
585-
return;
586-
}
587-
588-
// If the clarin license is not null that means some clarin license was updated and accepted
589-
// Attach the new clarin license to every bitstream and add clarin license values to the item metadata.
590-
591-
// update item metadata with license data
592-
clarinLicenseService.addLicenseMetadataToItem(context, clarinLicense, item);
593-
594-
// Attach the clarin license to the bitstreams
595-
for (Bundle bundle : bundles) {
596-
List<Bitstream> bitstreamList = bundle.getBitstreams();
597-
for (Bitstream bitstream : bitstreamList) {
598-
// in case bitstream ID exists in license table for some reason .. just remove it
599-
this.clarinLicenseResourceMappingService.attachLicense(context, clarinLicense, bitstream);
600-
}
557+
// Delegate to the shared helper so the legacy `/license` path and the
558+
// section path `/sections/clarin-license/select` apply the same logic.
559+
try {
560+
ClarinLicenseSubmissionUtils.applyLicense(context, item, clarinLicenseName);
561+
} catch (ClarinLicenseNotFoundException ex) {
562+
throw new UnprocessableEntityException(ex.getMessage(), ex);
601563
}
602-
603-
// Save changes to database
604-
itemService.update(context, item);
605564
}
606565

607566
private void grantDistributionLicense(Context context, WorkspaceItem source, Operation op)

dspace-server-webapp/src/main/java/org/dspace/app/rest/submit/step/ClarinLicenseResourceStep.java

Lines changed: 126 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,68 +7,160 @@
77
*/
88
package org.dspace.app.rest.submit.step;
99

10+
import java.util.List;
1011
import javax.servlet.http.HttpServletRequest;
1112

12-
import org.atteo.evo.inflector.English;
13+
import com.fasterxml.jackson.databind.JsonNode;
14+
import org.apache.commons.collections4.CollectionUtils;
15+
import org.apache.commons.lang3.StringUtils;
16+
import org.dspace.app.rest.exception.ClarinLicenseNotFoundException;
1317
import org.dspace.app.rest.exception.UnprocessableEntityException;
14-
import org.dspace.app.rest.model.BitstreamRest;
18+
import org.dspace.app.rest.model.patch.JsonValueEvaluator;
1519
import org.dspace.app.rest.model.patch.Operation;
16-
import org.dspace.app.rest.model.step.DataLicense;
20+
import org.dspace.app.rest.model.patch.ReplaceOperation;
21+
import org.dspace.app.rest.model.step.ClarinDataLicense;
1722
import org.dspace.app.rest.submit.AbstractProcessingStep;
1823
import org.dspace.app.rest.submit.SubmissionService;
19-
import org.dspace.app.rest.submit.factory.PatchOperationFactory;
20-
import org.dspace.app.rest.submit.factory.impl.PatchOperation;
2124
import org.dspace.app.util.SubmissionStepConfig;
22-
import org.dspace.content.Bitstream;
2325
import org.dspace.content.InProgressSubmission;
24-
import org.dspace.core.Constants;
26+
import org.dspace.content.Item;
27+
import org.dspace.content.MetadataValue;
2528
import org.dspace.core.Context;
29+
import org.slf4j.Logger;
30+
import org.slf4j.LoggerFactory;
2631

2732
/**
28-
* Clarin License Resource License step for DSpace Spring Rest. This Step will show license selector
29-
* where the user could choose license for the bitstream.
33+
* Submission step exposing the CLARIN resource license selected for the
34+
* in-progress submission. Data is sourced from the item's {@code dc.rights*}
35+
* metadata; the selection is updated via a section-scoped patch
36+
* {@code /sections/clarin-license/select}.
3037
*
3138
* @author Milan Majchrak (milan.majchrak at dataquest.sk)
32-
*
33-
* This class is inspired by the class LicenseStep created by
34-
* @author Luigi Andrea Pascarelli (luigiandrea.pascarelli at 4science.it)
35-
*
3639
*/
3740
public class ClarinLicenseResourceStep extends AbstractProcessingStep {
3841

39-
private static final String DCTERMS_RIGHTSDATE = "dcterms.accessRights";
42+
private static final Logger log = LoggerFactory.getLogger(ClarinLicenseResourceStep.class);
43+
44+
/**
45+
* Sub-path of the section patch used to select a CLARIN license by name,
46+
* e.g. {@code /sections/clarin-license/select}.
47+
*/
48+
private static final String LICENSE_SELECT_OPERATION_ENTRY = "select";
4049

4150
@Override
42-
public DataLicense getData(SubmissionService submissionService, InProgressSubmission obj,
43-
SubmissionStepConfig config)
44-
throws Exception {
45-
DataLicense result = new DataLicense();
46-
Bitstream bitstream = bitstreamService
47-
.getBitstreamByName(obj.getItem(), Constants.LICENSE_BUNDLE_NAME, Constants.LICENSE_BITSTREAM_NAME);
48-
if (bitstream != null) {
49-
String acceptanceDate = bitstreamService.getMetadata(bitstream, DCTERMS_RIGHTSDATE);
50-
result.setAcceptanceDate(acceptanceDate);
51-
result.setUrl(
52-
configurationService.getProperty("dspace.server.url")
53-
+ "/api/" + BitstreamRest.CATEGORY + "/" + English
54-
.plural(BitstreamRest.NAME) + "/" + bitstream.getID() + "/content");
55-
result.setGranted(true);
51+
public ClarinDataLicense getData(SubmissionService submissionService, InProgressSubmission obj,
52+
SubmissionStepConfig config) {
53+
ClarinDataLicense result = new ClarinDataLicense();
54+
Item item = obj.getItem();
55+
if (item == null) {
56+
return result;
57+
}
58+
59+
List<MetadataValue> name = itemService.getMetadataByMetadataString(item, "dc.rights");
60+
List<MetadataValue> uri = itemService.getMetadataByMetadataString(item, "dc.rights.uri");
61+
List<MetadataValue> label = itemService.getMetadataByMetadataString(item, "dc.rights.label");
62+
63+
if (CollectionUtils.isNotEmpty(name)) {
64+
result.setName(name.get(0).getValue());
65+
}
66+
if (CollectionUtils.isNotEmpty(uri)) {
67+
result.setDefinition(uri.get(0).getValue());
68+
}
69+
if (CollectionUtils.isNotEmpty(label)) {
70+
result.setLabel(label.get(0).getValue());
5671
}
72+
result.setGranted(CollectionUtils.isNotEmpty(name)
73+
&& CollectionUtils.isNotEmpty(uri)
74+
&& CollectionUtils.isNotEmpty(label));
5775
return result;
5876
}
5977

6078
@Override
6179
public void doPatchProcessing(Context context, HttpServletRequest currentRequest, InProgressSubmission source,
6280
Operation op, SubmissionStepConfig stepConf) throws Exception {
6381

64-
if (op.getPath().endsWith(LICENSE_STEP_OPERATION_ENTRY)) {
82+
String path = op.getPath();
6583

66-
PatchOperation<String> patchOperation = new PatchOperationFactory()
67-
.instanceOf(LICENSE_STEP_OPERATION_ENTRY, op.getOp());
68-
patchOperation.perform(context, currentRequest, source, op);
84+
if (path.endsWith("/" + LICENSE_SELECT_OPERATION_ENTRY)) {
85+
if (!(op instanceof ReplaceOperation)) {
86+
throw new UnprocessableEntityException(
87+
"The operation '" + op.getOp() + "' is not supported for path " + path);
88+
}
89+
String licenseName = extractLicenseName(op);
90+
// Section endpoint: a missing or blank license name is treated as a
91+
// client error (422). The legacy `/license` path in
92+
// WorkspaceItemRestRepository intentionally treats a blank value as
93+
// "clear the current license" for backwards compatibility.
94+
if (StringUtils.isBlank(licenseName)) {
95+
throw new UnprocessableEntityException(
96+
"The patch value for path " + path + " must contain a non-empty license name.");
97+
}
98+
try {
99+
ClarinLicenseSubmissionUtils.applyLicense(context, source.getItem(), licenseName);
100+
} catch (ClarinLicenseNotFoundException ex) {
101+
// Surface invalid client input as 422 instead of leaking as 500.
102+
throw new UnprocessableEntityException(ex.getMessage(), ex);
103+
}
104+
return;
105+
}
106+
107+
if (path.endsWith(LICENSE_STEP_OPERATION_ENTRY)) {
108+
// `granted` patches are a no-op on this section; kept for older clients.
109+
log.info("Ignoring legacy '{}/granted' patch on the CLARIN license section.", stepConf.getId());
110+
return;
111+
}
69112

70-
} else {
71-
throw new UnprocessableEntityException("The path " + op.getPath() + " cannot be patched");
113+
throw new UnprocessableEntityException("The path " + path + " cannot be patched");
114+
}
115+
116+
/**
117+
* Extract the CLARIN license name from a JSON Patch {@link Operation}.
118+
* <p>
119+
* The submission API receives section updates as JSON Patch operations
120+
* (see {@code /sections/clarin-license/select}). The {@code value} field
121+
* of such an operation is not strongly typed: depending on the request
122+
* shape and how the JSON Patch payload was parsed upstream, it can arrive
123+
* as:
124+
* <ul>
125+
* <li>a plain {@link String}, e.g. {@code "value": "CC-BY"};</li>
126+
* <li>a {@link JsonValueEvaluator} wrapping a {@link JsonNode}, when the
127+
* payload is sent as a JSON object such as
128+
* {@code "value": { "value": "CC-BY" }} or as a bare textual node;</li>
129+
* <li>{@code null} when the client omitted the value entirely.</li>
130+
* </ul>
131+
* This helper normalizes those cases into a single {@code String} license
132+
* name (or {@code null} if no usable value is present), so the rest of the
133+
* step can call {@link ClarinLicenseSubmissionUtils#applyLicense} with a
134+
* simple value and treat missing input as a client error.
135+
*
136+
* @param op the JSON Patch operation targeting the license {@code select} path
137+
* @return the license name extracted from the operation value, or {@code null}
138+
* if the operation has no usable value (missing, null, or of an
139+
* unsupported type)
140+
*/
141+
private String extractLicenseName(Operation op) {
142+
Object value = op.getValue();
143+
if (value == null) {
144+
return null;
145+
}
146+
if (value instanceof String) {
147+
return (String) value;
148+
}
149+
if (value instanceof JsonValueEvaluator) {
150+
JsonNode valueNode = ((JsonValueEvaluator) value).getValueNode();
151+
if (valueNode == null) {
152+
return null;
153+
}
154+
JsonNode inner = valueNode.get("value");
155+
if (inner != null) {
156+
return inner.asText();
157+
}
158+
if (valueNode.isTextual()) {
159+
return valueNode.asText();
160+
}
72161
}
162+
log.warn("Unsupported Operation value type for license name extraction: {}",
163+
value.getClass().getName());
164+
return null;
73165
}
74166
}

0 commit comments

Comments
 (0)