Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/server-rm/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@
<groupId>co.oslc.refimpl</groupId>
<artifactId>lib-common</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.lyo</groupId>
<artifactId>lyo-validation</artifactId>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Candidate for upstreaming to lyo-server-common or other server capability. Also good to consider a Jakarta prodivider for ValidatedResource<T> where a REST handler method does not need to do validation manually.

private RequirementValidation() {
}

public static <T extends AbstractResource> ValidatedResource<T> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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 extends AbstractResource>(T resource, boolean valid, ValidationReport report) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Candidate for upstreaming to lyo-validation

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, Requirement> requirements = requirementsForSP(serviceProviderId);
aResource.setAbout(resourcesFactory.constructURIForRequirement(serviceProviderId, aResource.getIdentifier()));
aResource.setCreated(new Date());
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ValidationResult> getResult() {
throw new AssertionError("Report details should not be read eagerly");
}
};
ValidatedResource<Requirement> invalid = new ValidatedResource<>(new Requirement(), false, report);
assertSame(report, invalid.report());
assertThrows(AssertionError.class, invalid::getSummaryMessage);

ValidatedResource<Requirement> 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<Requirement> 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<Requirement> 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<Requirement> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
Loading