diff --git a/README.md b/README.md
index 1e522bd3..8510033c 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,12 @@ Reference implementation of four OSLC Servers covering each of the 4 OSLC Domain
* [Architecture Management](https://oslc-op.github.io/oslc-specs/specs/am/architecture-management-spec.html)
## How to run
+RM Requirement POST and PUT requests are validated using `lyo-validation` against
+the resource class's OSLC annotations. Title (`dcterms:title`) and identifier
+(`dcterms:identifier`) are required. Invalid requirements return HTTP 400 with
+plain-text property-level validation details, without storing the resource or
+emitting a TRS event.
+
Follow the 3 sections below to:
1. [Run the OSLC Servers](#run-the-oslc-servers)
diff --git a/src/server-rm/pom.xml b/src/server-rm/pom.xml
index db98eda7..eaa0afd9 100644
--- a/src/server-rm/pom.xml
+++ b/src/server-rm/pom.xml
@@ -164,6 +164,10 @@
co.oslc.refimpl
lib-common
+
+ org.eclipse.lyo
+ lyo-validation
+
org.apache.lucene
lucene-core
diff --git a/src/server-rm/src/main/java/co/oslc/refimpl/rm/RequirementValidation.java b/src/server-rm/src/main/java/co/oslc/refimpl/rm/RequirementValidation.java
new file mode 100644
index 00000000..1272c042
--- /dev/null
+++ b/src/server-rm/src/main/java/co/oslc/refimpl/rm/RequirementValidation.java
@@ -0,0 +1,48 @@
+package co.oslc.refimpl.rm;
+
+import java.lang.reflect.InvocationTargetException;
+import java.net.URISyntaxException;
+import java.text.ParseException;
+import javax.xml.datatype.DatatypeConfigurationException;
+
+import jakarta.ws.rs.BadRequestException;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import org.eclipse.lyo.oslc4j.core.model.AbstractResource;
+import org.eclipse.lyo.oslc4j.core.exception.OslcCoreApplicationException;
+import org.eclipse.lyo.shacl.ValidationReport;
+import org.eclipse.lyo.validation.ValidatorFactory;
+
+public final class RequirementValidation {
+ private RequirementValidation() {
+ }
+
+ public static ValidatedResource validate(T resource) {
+ if (resource == null) {
+ return new ValidatedResource<>(null, false, null);
+ }
+
+ final ValidationReport report;
+ try {
+ report = ValidatorFactory.createValidator().validate(resource);
+ } catch (OslcCoreApplicationException | URISyntaxException | ParseException
+ | IllegalAccessException | InvocationTargetException | DatatypeConfigurationException
+ | InstantiationException | NoSuchMethodException exception) {
+ throw new IllegalStateException("Unable to validate resource", exception);
+ }
+
+ return new ValidatedResource<>(resource, Boolean.TRUE.equals(report.isConforms()), report);
+ }
+
+ public static void throwBadRequestIfInvalid(ValidatedResource> validatedResource) {
+ if (validatedResource.valid()) {
+ return;
+ }
+ throw badRequest(validatedResource.getSummaryMessage());
+ }
+
+ private static BadRequestException badRequest(String message) {
+ return new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
+ .type(MediaType.TEXT_PLAIN_TYPE).entity(message).build());
+ }
+}
diff --git a/src/server-rm/src/main/java/co/oslc/refimpl/rm/ValidatedResource.java b/src/server-rm/src/main/java/co/oslc/refimpl/rm/ValidatedResource.java
new file mode 100644
index 00000000..c444b18e
--- /dev/null
+++ b/src/server-rm/src/main/java/co/oslc/refimpl/rm/ValidatedResource.java
@@ -0,0 +1,23 @@
+package co.oslc.refimpl.rm;
+
+import java.util.stream.Collectors;
+
+import org.eclipse.lyo.oslc4j.core.model.AbstractResource;
+import org.eclipse.lyo.shacl.ValidationReport;
+
+/** A validation result; the report is absent when the resource itself is null. */
+public record ValidatedResource(T resource, boolean valid, ValidationReport report) {
+ public String getSummaryMessage() {
+ if (valid) {
+ return "Resource validation passed";
+ }
+ if (resource == null) {
+ return "A resource is required";
+ }
+ String details = report.getResult().stream()
+ .map(result -> result.getResultPath() + ": " + result.getMessage())
+ .sorted()
+ .collect(Collectors.joining("; "));
+ return "Resource validation failed: " + details;
+ }
+}
diff --git a/src/server-rm/src/main/java/co/oslc/refimpl/rm/gen/RestDelegate.java b/src/server-rm/src/main/java/co/oslc/refimpl/rm/gen/RestDelegate.java
index 64e45ece..33d9e42d 100644
--- a/src/server-rm/src/main/java/co/oslc/refimpl/rm/gen/RestDelegate.java
+++ b/src/server-rm/src/main/java/co/oslc/refimpl/rm/gen/RestDelegate.java
@@ -47,6 +47,7 @@
// Start of user code imports
+import co.oslc.refimpl.rm.RequirementValidation;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
@@ -245,6 +246,8 @@ public Requirement createRequirement(HttpServletRequest httpServletRequest, fina
// Start of user code createRequirement
+ var validatedResource = RequirementValidation.validate(aResource);
+ RequirementValidation.throwBadRequestIfInvalid(validatedResource);
final Map requirements = requirementsForSP(serviceProviderId);
aResource.setAbout(resourcesFactory.constructURIForRequirement(serviceProviderId, aResource.getIdentifier()));
aResource.setCreated(new Date());
@@ -377,6 +380,8 @@ public Requirement updateRequirement(HttpServletRequest httpServletRequest, fina
Requirement updatedResource = null;
// Start of user code updateRequirement
+ var validatedResource = RequirementValidation.validate(aResource);
+ RequirementValidation.throwBadRequestIfInvalid(validatedResource);
if(!resourcesFactory.constructURIForRequirement(serviceProviderId, resourceId).equals(aResource.getAbout())) {
throw new WebApplicationException("Subject URI shall match the endpoint", Response.Status.BAD_REQUEST);
}
diff --git a/src/server-rm/src/test/java/co/oslc/refimpl/rm/ValidatedResourceTest.java b/src/server-rm/src/test/java/co/oslc/refimpl/rm/ValidatedResourceTest.java
new file mode 100644
index 00000000..8f154d3a
--- /dev/null
+++ b/src/server-rm/src/test/java/co/oslc/refimpl/rm/ValidatedResourceTest.java
@@ -0,0 +1,83 @@
+package co.oslc.refimpl.rm;
+
+import java.util.HashSet;
+
+import jakarta.ws.rs.BadRequestException;
+import org.eclipse.lyo.oslc.domains.rm.Requirement;
+import org.eclipse.lyo.shacl.ValidationReport;
+import org.eclipse.lyo.shacl.ValidationResult;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+public class ValidatedResourceTest {
+ @Test
+ public void summaryIsOnlyCalculatedOnDemand() throws Exception {
+ ValidationReport report = new ValidationReport() {
+ @Override
+ public HashSet getResult() {
+ throw new AssertionError("Report details should not be read eagerly");
+ }
+ };
+ ValidatedResource invalid = new ValidatedResource<>(new Requirement(), false, report);
+ assertSame(report, invalid.report());
+ assertThrows(AssertionError.class, invalid::getSummaryMessage);
+
+ ValidatedResource valid = new ValidatedResource<>(new Requirement(), true, report);
+ RequirementValidation.throwBadRequestIfInvalid(valid);
+ assertEquals("Resource validation passed", valid.getSummaryMessage());
+ }
+
+ @Test
+ public void validResourceRetainsResourceAndReport() {
+ Requirement requirement = new Requirement();
+ requirement.setTitle("A requirement");
+ requirement.setIdentifier("id");
+
+ ValidatedResource result = RequirementValidation.validate(requirement);
+
+ assertSame(requirement, result.resource());
+ assertTrue(result.valid());
+ assertTrue(result.report().isConforms());
+ RequirementValidation.throwBadRequestIfInvalid(result);
+ }
+
+ @Test
+ public void invalidResourceReturnsReportWithoutThrowing() {
+ Requirement requirement = new Requirement();
+ ValidatedResource result = RequirementValidation.validate(requirement);
+
+ assertSame(requirement, result.resource());
+ assertFalse(result.valid());
+ assertFalse(result.report().isConforms());
+ assertTrue(result.report().getResult().stream()
+ .anyMatch(error -> error.getResultPath().toString().endsWith("/title")));
+ assertTrue(result.report().getResult().stream()
+ .anyMatch(error -> error.getResultPath().toString().endsWith("/identifier")));
+
+ BadRequestException exception = assertThrows(BadRequestException.class,
+ () -> RequirementValidation.throwBadRequestIfInvalid(result));
+ assertEquals(400, exception.getResponse().getStatus());
+ assertEquals(result.getSummaryMessage(), exception.getResponse().getEntity());
+ assertTrue(result.getSummaryMessage().contains("http://purl.org/dc/terms/title"));
+ assertTrue(result.getSummaryMessage().contains("http://purl.org/dc/terms/identifier"));
+ }
+
+ @Test
+ public void nullResourceReturnsInvalidResultWithoutThrowing() {
+ ValidatedResource result = RequirementValidation.validate(null);
+
+ assertFalse(result.valid());
+ assertNull(result.resource());
+ assertNull(result.report());
+ assertEquals("A resource is required", result.getSummaryMessage());
+ BadRequestException exception = assertThrows(BadRequestException.class,
+ () -> RequirementValidation.throwBadRequestIfInvalid(result));
+ assertEquals(400, exception.getResponse().getStatus());
+ }
+}
diff --git a/src/server-rm/src/test/java/co/oslc/refimpl/rm/gen/RequirementValidationTest.java b/src/server-rm/src/test/java/co/oslc/refimpl/rm/gen/RequirementValidationTest.java
new file mode 100644
index 00000000..4d2593f2
--- /dev/null
+++ b/src/server-rm/src/test/java/co/oslc/refimpl/rm/gen/RequirementValidationTest.java
@@ -0,0 +1,165 @@
+package co.oslc.refimpl.rm.gen;
+
+import java.net.URI;
+import java.util.UUID;
+
+import jakarta.ws.rs.BadRequestException;
+import org.eclipse.lyo.oslc.domains.rm.Requirement;
+import org.eclipse.lyo.oslc4j.core.model.IResource;
+import org.eclipse.lyo.oslc4j.trs.server.TrsEventHandler;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class RequirementValidationTest {
+ private RestDelegate delegate;
+ private String serviceProviderId;
+ private RecordingEvents events;
+
+ @Before
+ public void setUp() {
+ serviceProviderId = UUID.randomUUID().toString();
+ delegate = new RestDelegate();
+ delegate.resourcesFactory = new ResourcesFactory("http://example.org/");
+ events = new RecordingEvents();
+ delegate.trsEventHandler = events;
+ }
+
+ @Test
+ public void createRejectsMissingTitle() {
+ assertInvalidCreate("title");
+ }
+
+ @Test
+ public void createRejectsMissingIdentifier() {
+ assertInvalidCreate("identifier");
+ }
+
+ @Test
+ public void updateRejectsMissingTitle() {
+ assertInvalidUpdate("title");
+ }
+
+ @Test
+ public void updateRejectsMissingIdentifier() {
+ assertInvalidUpdate("identifier");
+ }
+
+ @Test
+ public void createAcceptsValidRequirementWithoutSubject() {
+ Requirement requirement = validRequirement();
+ assertNull(requirement.getAbout());
+ assertSame(requirement, delegate.createRequirement(null, requirement, serviceProviderId));
+ assertEquals(subject(), requirement.getAbout());
+ assertNotNull(requirement.getCreated());
+ assertSame(requirement, delegate.getRequirement(null, serviceProviderId, "id"));
+ assertEquals(1, events.created);
+ }
+
+ @Test
+ public void updateAcceptsValidRequirement() {
+ Requirement original = validRequirement();
+ RestDelegate.requirementsForSP(serviceProviderId).put("id", original);
+ Requirement replacement = validRequirement();
+ replacement.setAbout(subject());
+ assertSame(replacement, delegate.updateRequirement(null, replacement, serviceProviderId, "id"));
+ assertSame(replacement, delegate.getRequirement(null, serviceProviderId, "id"));
+ assertNotNull(replacement.getModified());
+ assertEquals(1, events.modified);
+ }
+
+ @Test
+ public void updateStillRejectsMismatchedSubject() {
+ Requirement requirement = validRequirement();
+ requirement.setAbout(URI.create("http://example.org/wrong"));
+ jakarta.ws.rs.WebApplicationException exception = assertThrows(jakarta.ws.rs.WebApplicationException.class,
+ () -> delegate.updateRequirement(null, requirement, serviceProviderId, "id"));
+ assertEquals(400, exception.getResponse().getStatus());
+ assertNull(requirement.getModified());
+ assertEquals(0, events.modified);
+ }
+
+ @Test
+ public void createRejectsNullBody() {
+ BadRequestException exception = assertThrows(BadRequestException.class,
+ () -> delegate.createRequirement(null, null, serviceProviderId));
+ assertEquals(400, exception.getResponse().getStatus());
+ assertEquals(0, events.created);
+ }
+
+ private void assertInvalidCreate(String property) {
+ Requirement requirement = invalidRequirement(property);
+ assertViolation(property, () -> delegate.createRequirement(null, requirement, serviceProviderId));
+ assertTrue(RestDelegate.requirementsForSP(serviceProviderId).isEmpty());
+ assertNull(requirement.getAbout());
+ assertNull(requirement.getCreated());
+ assertEquals(0, events.created);
+ }
+
+ private void assertInvalidUpdate(String property) {
+ Requirement original = validRequirement();
+ RestDelegate.requirementsForSP(serviceProviderId).put("id", original);
+ Requirement replacement = invalidRequirement(property);
+ replacement.setAbout(subject());
+ assertViolation(property, () -> delegate.updateRequirement(null, replacement, serviceProviderId, "id"));
+ assertSame(original, delegate.getRequirement(null, serviceProviderId, "id"));
+ assertNull(replacement.getModified());
+ assertEquals(0, events.modified);
+ }
+
+ private void assertViolation(String property, org.junit.function.ThrowingRunnable action) {
+ BadRequestException exception = assertThrows(BadRequestException.class, action);
+ assertEquals(400, exception.getResponse().getStatus());
+ assertEquals("text/plain", exception.getResponse().getMediaType().toString());
+ assertTrue(exception.getResponse().getEntity().toString()
+ .contains("http://purl.org/dc/terms/" + property));
+ }
+
+ private Requirement invalidRequirement(String property) {
+ Requirement requirement = validRequirement();
+ if ("title".equals(property)) {
+ requirement.setTitle(null);
+ } else {
+ requirement.setIdentifier(null);
+ }
+ return requirement;
+ }
+
+ private Requirement validRequirement() {
+ Requirement requirement = new Requirement();
+ requirement.setTitle("A requirement");
+ requirement.setIdentifier("id");
+ return requirement;
+ }
+
+ private URI subject() {
+ return delegate.resourcesFactory.constructURIForRequirement(serviceProviderId, "id");
+ }
+
+ private static class RecordingEvents implements TrsEventHandler {
+ private int created;
+ private int modified;
+
+ @Override
+ public void onCreated(IResource resource) {
+ created++;
+ }
+
+ @Override
+ public void onModified(IResource resource) {
+ modified++;
+ }
+
+ @Override
+ public void onDeleted(URI resourceUri) {
+ fail("Unexpected deletion");
+ }
+ }
+}