Skip to content
Merged
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
8 changes: 7 additions & 1 deletion pgp-keys-override.list
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,10 @@ org.cyclonedx:cyclonedx-maven-plugin = 0xFA77DCFEF2EE6EB2DEBEDD2C012579464D01C06
org.sonatype.central:central-publishing-maven-plugin:0.9.0 = 0x27C7B9756A9C691EE87696FB5C9BD9F4EAAD7054

# QBiC Linksmith library to craft weblinks
life.qbic:linksmith:1.0.0-alpha.2 = 0x701E55A5314556757C7D3ABCC5566B352DF7E74D
life.qbic:linksmith:1.0.0-alpha.3 = 0x701E55A5314556757C7D3ABCC5566B352DF7E74D

# Jackson core
tools.jackson.core:jackson-core:3.0.3 = 0x28118C070CB22A0175A2E8D43D12CA2AC19F3181

# Jackson databind
tools.jackson.core:jackson-databind:3.0.3 = 0x28118C070CB22A0175A2E8D43D12CA2AC19F3181
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<linksmith.version>1.0.0-alpha.2</linksmith.version>
<linksmith.version>1.0.0-alpha.3</linksmith.version>
<jackson.version>3.0.3</jackson.version>
<groovy.version>5.0.0-alpha-12</groovy.version>
<spock.version>2.4-M7-groovy-5.0</spock.version>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,55 +10,60 @@
import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;

/**
* Signposting validator for <strong>Level&nbsp;1</strong> of the FAIR Signposting profile.
* Validator for the FAIR Signposting <strong>Level&nbsp;1</strong> profile.
*
* <h2>Scope and normative target</h2>
* <p>
* FAIR Signposting Level&nbsp;1 defines a <em>minimal</em> set of typed links intended to support
* robust machine navigation of scholarly objects. Importantly, in Level&nbsp;1 only the
* <strong>landing page recipe</strong> is <strong>mandatory</strong> and therefore
* <strong>normative</strong>.
* </p>
*
* <p>
* Level&nbsp;1 Signposting describes the <em>minimal, inline</em> set of typed links that a
* scholarly object should expose to enable reliable discovery, citation, attribution,
* and access to descriptive metadata. These links are typically provided via HTTP
* {@code Link} headers or HTML {@code <link>} elements and are directly attached to the
* resource itself.
* Typed links exposed on <em>content resources</em> and <em>metadata resources</em> are
* <strong>recommended</strong> in Level&nbsp;1 but not required by the profile. Consequently, this
* validator deliberately does <strong>not</strong> attempt to infer, validate, or enforce completeness
* of Level&nbsp;1 content/metadata resource recipes. It validates only the Level&nbsp;1
* <strong>landing page</strong> expectations on the provided links.
* </p>
*
* <h2>What this validator checks</h2>
* <p>
* This validator operates on a list of already parsed {@link WebLink}s and performs
* structural and semantic checks according to the Level&nbsp;1 FAIR Signposting
* recommendations:
* The validator operates on a list of already parsed {@link WebLink}s (e.g., obtained from HTTP
* {@code Link} headers or HTML {@code <link>} elements) and records issues according to the
* Level&nbsp;1 landing page recipe:
* </p>
*
* <ul>
* <li>
* <strong>{@code rel="author"}</strong> – recommended; a warning is raised if no author
* relation is present (cardinality 0..n).
* <strong>{@code rel="cite-as"}</strong> – <strong>mandatory</strong> for the landing page;
* exactly one link is expected (error if missing or duplicated).
* </li>
* <li>
* <strong>{@code rel="cite-as"}</strong> – mandatory; exactly one occurrence is expected,
* and an error is raised if missing or duplicated.
* <strong>{@code rel="describedby"}</strong> – <strong>mandatory</strong> for the landing page;
* at least one link is expected (error if missing).
* </li>
* <li>
* <strong>{@code rel="describedby"}</strong> – mandatory; at least one occurrence is required
* to point to metadata describing the resource.
* <strong>{@code rel="author"}</strong> – <strong>recommended</strong> for the landing page;
* a warning is recorded if absent (cardinality 0..n).
* </li>
* <li>
* <strong>Target URI scheme</strong> – all link targets are checked for secure transport;
* non-HTTPS or non-HTTP targets result in warnings.
* <strong>Transport security</strong> – emits warnings for link targets that are not HTTPS.
* </li>
* </ul>
*
* <p>
* The validator is intentionally <strong>non-fatal</strong> where possible: it collects
* errors and warnings for all detected issues instead of aborting on the first violation.
* </p>
*
* <p>
* This class does <strong>not</strong> dereference links, verify identifier persistence,
* or validate the contents of metadata resources. Its responsibility is limited to
* validating the presence, cardinality, and basic properties of Level&nbsp;1 Signposting
* relations as they appear in the provided WebLinks.
* </p>
* <h2>Non-goals</h2>
* <ul>
* <li>No dereferencing of link targets (no network access).</li>
* <li>No validation of metadata payloads or identifier persistence.</li>
* <li>No enforcement of Level&nbsp;1 recommendations for content/metadata resources.</li>
* </ul>
*
* <p>
* The resulting {@link SignPostingView} is a semantic convenience wrapper around the
* original WebLinks; no links are filtered or modified during validation.
* The returned {@link SignPostingView} is a semantic, read-only convenience wrapper around the
* original WebLinks. Validation issues are reported via {@link IssueReport}; the view itself does
* not modify or filter links.
* </p>
*
* @author Sven Fillinger
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
package life.qbic.compass.validation;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import life.qbic.compass.model.SignPostingView;
import life.qbic.compass.spi.SignPostingResult;
import life.qbic.compass.spi.SignPostingValidator;
import life.qbic.linksmith.model.WebLink;
import life.qbic.linksmith.spi.WebLinkValidator.Issue;
import life.qbic.linksmith.spi.WebLinkValidator.IssueReport;

/**
* Validates the FAIR Signposting <em>Level 2</em> <strong>content resource recipe</strong>
* for a single content resource context.
*
* <p>
* In FAIR Signposting Level 2, typed links for multiple resources are typically conveyed via a
* Link Set (e.g., {@code application/linkset+json}). A content resource is one of the resources
* in that graph (e.g., a PDF, CSV, ZIP, etc.). This validator checks whether a given list of
* {@link WebLink}s satisfies the expected relation set and cardinalities for the content resource
* context.
* </p>
*
* <h2>How this validator determines the context</h2>
* <p>
* This validator does <strong>not</strong> assume the input is pre-grouped by anchor.
* It uses {@link Level2Util#validateForSingleAnchor(List, List, List, Map)} to ensure that all
* links in the input belong to exactly one anchor (origin) value.
* </p>
*
* <ul>
* <li>If multiple distinct anchors are present, the context is ambiguous and recipe validation is aborted.</li>
* <li>If one or more links are missing an {@code anchor} value, recipe validation is aborted and errors are recorded.</li>
* </ul>
*
* <h2>Null handling contract</h2>
* <p>
* The input list itself must not be {@code null}. If {@code null} elements are contained in the
* list, they are:</p>
* <ul>
* <li>passed to {@link Level2Util#validateForSingleAnchor(List, List, List, Map)} which is expected to record issues, and</li>
* <li>filtered out when constructing the {@link SignPostingView} to prevent {@link NullPointerException}
* and to keep the result consumable for clients.</li>
* </ul>
*
* <h2>Validated relations and cardinalities</h2>
* <p>
* For the content resource recipe, this validator checks the following relations (as expressed in
* {@code rel=} parameters):
* </p>
* <ul>
* <li>{@code collection}: mandatory, cardinality {@code (1)}</li>
* <li>{@code cite-as}: optional, cardinality {@code (0..1)}</li>
* <li>{@code license}: optional, cardinality {@code (0..1)}</li>
* <li>{@code type}: optional, cardinality {@code (0..1)}</li>
* </ul>
*
* <p>
* This validator does not dereference targets, does not validate media types, and does not validate
* URI semantics beyond what the upstream Link parsing/validation already guarantees.
* </p>
*
* @since 1.0.0
* @author Sven Fillinger
*/
public class Level2ContentResourceValidator implements SignPostingValidator {

/** Relation type {@code cite-as} used by FAIR Signposting. */
public static final String CITE_AS = "cite-as";

/** Relation type {@code collection} used by FAIR Signposting for content resources. */
public static final String COLLECTION = "collection";

/** Relation type {@code license} used by FAIR Signposting. */
public static final String LICENSE = "license";

/** Relation type {@code type} used by FAIR Signposting (semantic typing). */
public static final String TYPE = "type";

/**
* Private constructor. Use {@link #create()}.
*/
private Level2ContentResourceValidator() {
}

/**
* Creates a new validator instance.
*
* <p>
* The validator is stateless; instances can be reused.
* </p>
*
* @return a new {@link SignPostingValidator} validating the Level 2 content resource recipe
*/
public static SignPostingValidator create() {
return new Level2ContentResourceValidator();
}

/**
* Validates the provided list of {@link WebLink}s against the FAIR Signposting Level 2 content
* resource recipe.
*
* <p>
* The returned {@link SignPostingResult} always contains a {@link SignPostingView}. The view is
* built from a filtered copy of the input list with all {@code null} elements removed.
* </p>
*
* <p>
* Validation is best-effort and issue-driven:
* </p>
* <ul>
* <li>If the input contains multiple distinct anchor values, validation aborts after recording an error.</li>
* <li>If the input contains links without an {@code anchor} value, validation aborts after recording errors.</li>
* <li>Otherwise, relation cardinalities are evaluated and corresponding issues are recorded.</li>
* </ul>
*
* @param webLinks the weblinks to validate; must not be {@code null}
* @return the validation result containing a view and an issue report
* @throws NullPointerException if {@code webLinks} is {@code null}
*/
@Override
public SignPostingResult validate(List<WebLink> webLinks) {
Objects.requireNonNull(webLinks);
var issues = new ArrayList<Issue>();
validateForContentResource(webLinks, issues);

return new SignPostingResult(
new SignPostingView(
webLinks.stream()
.filter(Objects::nonNull)
.toList()),
new IssueReport(issues));
}

/**
* Performs the actual recipe validation for a content resource context.
*
* <p>
* The method first checks whether all links belong to exactly one anchor (origin) context.
* If the context is ambiguous (multiple anchors) or incomplete (missing anchors), the method
* records issues and returns early without evaluating relation cardinalities.
* </p>
*
* @param webLinks the input list of links (may contain {@code null} elements)
* @param issues the mutable list used to record validation issues
*/
private void validateForContentResource(List<WebLink> webLinks, ArrayList<Issue> issues) {
var linksWithoutAnchor = new ArrayList<WebLink>();
var recordedRelations = new HashMap<String, Integer>();

var isContextUnique = Level2Util.validateForSingleAnchor(webLinks, issues, linksWithoutAnchor,
recordedRelations);

if (!isContextUnique) {
// Early abort: context ambiguity (e.g. multiple distinct anchors) prevents reliable recipe validation.
return;
}

// Validate and record issues for links without anchors
// Missing anchors violate Level 2 FAIR Signposting recipes, since the origin of the link
// cannot be determined
if (!linksWithoutAnchor.isEmpty()) {
linksWithoutAnchor.forEach(link -> issues.add(Issue.error(
"Found weblink with missing value for 'anchor'. Link target was '%s'".formatted(
link.target()))));
// Early abort without further cardinality evaluation
return;
}

// Validate for correct cardinality
// relation 'cite-as' cardinality is expected to be 0 or 1
// Validate relation cardinalities for the content resource recipe.
validateCiteAs(recordedRelations, issues); // (0..1)
validateCollection(recordedRelations, issues); // (1)
validateLicense(recordedRelations, issues); // (0..1)
validateType(recordedRelations, issues); // (0..1)
}

/**
* Validates the cardinality of relation {@code type}.
*
* <p>
* For the content resource recipe, {@code type} is optional but must not occur more than once
* within the selected anchor context.
* </p>
*
* @param recordedRelations map of relation type to occurrence count for the selected anchor context
* @param issues list used to record validation issues
*/
private static void validateType(HashMap<String, Integer> recordedRelations, ArrayList<Issue> issues) {
var count = recordedRelations.getOrDefault(TYPE, 0);
if (count > 1) {
issues.add(Issue.error(
"Multiple weblinks with relation '%s' (%d) found. Expected cardinality: (0..1)".formatted(TYPE,
count)));
}
}

/**
* Validates the cardinality of relation {@code license}.
*
* <p>
* For the content resource recipe, {@code license} is optional but must not occur more than once
* within the selected anchor context.
* </p>
*
* @param recordedRelations map of relation type to occurrence count for the selected anchor context
* @param issues list used to record validation issues
*/
private static void validateLicense(HashMap<String, Integer> recordedRelations,
ArrayList<Issue> issues) {
var count = recordedRelations.getOrDefault(LICENSE, 0);
if (count > 1) {
issues.add(Issue.error(
"Multiple weblinks with relation '%s' (%d) found. Expected cardinality: (0..1)".formatted(LICENSE,
count)));
}
}

/**
* Validates the presence and cardinality of relation {@code collection}.
*
* <p>
* For the content resource recipe, {@code collection} is mandatory and must occur exactly once
* within the selected anchor context.
* </p>
*
* @param recordedRelations map of relation type to occurrence count for the selected anchor context
* @param issues list used to record validation issues
*/
private static void validateCollection(HashMap<String, Integer> recordedRelations,
ArrayList<Issue> issues) {
validatePresenceOfMandatoryRelation(recordedRelations, issues, COLLECTION);
var count = recordedRelations.getOrDefault(COLLECTION, 0);
if (count > 1) {
issues.add(Issue.error(
"Multiple weblinks with relation '%s' (%d) found. Expected cardinality: (1)".formatted(COLLECTION,
count)));
}
}

/**
* Validates the cardinality of relation {@code cite-as}.
*
* <p>
* For the content resource recipe, {@code cite-as} is optional but must not occur more than once
* within the selected anchor context.
* </p>
*
* @param recordedRelations map of relation type to occurrence count for the selected anchor context
* @param issues list used to record validation issues
*/
private static void validateCiteAs(Map<String, Integer> recordedRelations, List<Issue> issues) {
var count = recordedRelations.getOrDefault(CITE_AS, 0);
if (count > 1) {
issues.add(Issue.error(
"Multiple weblinks with relation '%s' (%d) found. Expected cardinality: (0..1)".formatted(CITE_AS,
count)));
}
}

/**
* Records an error if a mandatory relation is missing.
*
* <p>
* Presence is defined by the existence of the relation key in {@code recordedRelations}.
* </p>
*
* @param recordedRelations relation counts collected for the selected anchor context
* @param issues the list used to record validation issues
* @param relation the relation type to validate
*/
private static void validatePresenceOfMandatoryRelation(
Map<String, Integer> recordedRelations,
List<Issue> issues, String relation) {
if (!recordedRelations.containsKey(relation)) {
issues.add(Issue.error("Missing mandatory relation type '%s'".formatted(relation)));
}
}
}
Loading
Loading