From f52f948e3b349a33031ce74e915553d0fea6e4ea Mon Sep 17 00:00:00 2001 From: Andrii Berezovskyi Date: Sun, 18 Oct 2026 19:28:48 +0200 Subject: [PATCH] Validate RM requirements before creation and update --- README.md | 6 + src/server-rm/pom.xml | 4 + .../refimpl/rm/RequirementValidation.java | 48 +++++ .../co/oslc/refimpl/rm/ValidatedResource.java | 23 +++ .../co/oslc/refimpl/rm/gen/RestDelegate.java | 5 + .../refimpl/rm/ValidatedResourceTest.java | 83 +++++++++ .../rm/gen/RequirementValidationTest.java | 165 ++++++++++++++++++ 7 files changed, 334 insertions(+) create mode 100644 src/server-rm/src/main/java/co/oslc/refimpl/rm/RequirementValidation.java create mode 100644 src/server-rm/src/main/java/co/oslc/refimpl/rm/ValidatedResource.java create mode 100644 src/server-rm/src/test/java/co/oslc/refimpl/rm/ValidatedResourceTest.java create mode 100644 src/server-rm/src/test/java/co/oslc/refimpl/rm/gen/RequirementValidationTest.java 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 a27a6a1d..4e2011fb 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"); + } + } +}