diff --git a/CITATION.cff b/CITATION.cff index bfc3a960..c92f7d15 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -10,6 +10,6 @@ authors: given-names: "Sabrine" orcid: "https://orcid.org/0000-0002-4480-6116" title: "ro-crate-java" -version: 1.0.3 +version: 2.1.0-rc3 date-released: 2022-07-19 url: "https://github.com/kit-data-manager/ro-crate-java" diff --git a/build.gradle b/build.gradle index f7fcd613..ae540446 100644 --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,7 @@ ext { dependencies { // JUnit setup for testing - testImplementation(platform("org.junit:junit-bom:5.12.2")) + testImplementation(platform("org.junit:junit-bom:5.13.0")) testImplementation('org.junit.jupiter:junit-jupiter') testRuntimeOnly('org.junit.platform:junit-platform-launcher') // JSON object mapping / (de-)serialization @@ -64,7 +64,7 @@ dependencies { // JSON-LD, Zenodo mapping implementation group: 'com.apicatalog', name: 'titanium-json-ld', version: '1.6.0' // metadata validation, profiles based on JSON schema - implementation group: "com.networknt", name: "json-schema-validator", version: "1.5.6" + implementation group: "com.networknt", name: "json-schema-validator", version: "1.5.7" implementation 'org.glassfish:jakarta.json:2.0.1' //JTE for template processing implementation('gg.jte:jte:3.2.1') @@ -111,6 +111,22 @@ configurations { performanceTestImplementation.extendsFrom implementation } +// Task for creating a resource file with the version info +tasks.register("generateVersionProps", WriteProperties) { t -> + def generatedResourcesDir = project.layout.buildDirectory.dir(["resources", "main"].join(File.separator)) + def outputFile = generatedResourcesDir.map { it.file("version.properties") } + + t.destinationFile = outputFile.get().asFile + t.property("version", version) +} + +tasks.register("generateVersionPropsTest", WriteProperties) { t -> + def generatedResourcesDir = project.layout.buildDirectory.dir(["resources", "test"].join(File.separator)) + def outputFile = generatedResourcesDir.map { it.file("version.properties") } + + t.destinationFile = outputFile.get().asFile + t.property("version", version) +} tasks.register('performanceContextEntitiesBenchmark', JavaExec) { description = "Run the context entities benchmarks." @@ -154,8 +170,13 @@ tasks.register('performanceReadWriteMultipleCratesBenchmark', JavaExec) { mainClass = 'edu.kit.datamanager.ro_crate.multiplecrates.MultipleCratesWriteAndRead' } +compileJava { + dependsOn generateVersionProps +} + test { useJUnitPlatform() + dependsOn generateVersionPropsTest finalizedBy jacocoTestReport } @@ -178,3 +199,5 @@ if (System.getProperty('profile') == "release") { println 'Using release profile for building ' + project.getName() apply from: 'gradle/profile-release.gradle' } + +apply from: 'gradle/updateCff.gradle' diff --git a/gradle/profile-release.gradle b/gradle/profile-release.gradle index 3af61472..471eedc8 100644 --- a/gradle/profile-release.gradle +++ b/gradle/profile-release.gradle @@ -17,7 +17,7 @@ apply plugin: 'net.researchgate.release' apply plugin: 'maven-publish' apply plugin: 'signing' - + //for plugin net.researchgate.release //see https://github.com/researchgate/gradle-release release { diff --git a/gradle/updateCff.gradle b/gradle/updateCff.gradle new file mode 100644 index 00000000..b4412fc3 --- /dev/null +++ b/gradle/updateCff.gradle @@ -0,0 +1,70 @@ +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** + * This file defines the updateCff task. + * + * This task hooks into the workflow of the release task, + * which, according to the source code of the release plugin, + * seems to be defined like this: + * + * tasks = [ + * "${p}createScmAdapter" as String, + * "${p}initScmAdapter" as String, + * "${p}checkCommitNeeded" as String, + * "${p}checkUpdateNeeded" as String, + * "${p}checkoutMergeToReleaseBranch" as String, + * "${p}unSnapshotVersion" as String, + * "${p}confirmReleaseVersion" as String, + * -> we insert our task here <- + * "${p}checkSnapshotDependencies" as String, + * "${p}runBuildTasks" as String, + * "${p}preTagCommit" as String, + * "${p}createReleaseTag" as String, + * "${p}checkoutMergeFromReleaseBranch" as String, + * "${p}updateVersion" as String, + * "${p}commitNewVersion" as String + * ] + */ + +tasks.register('updateCff') { + group = 'release' + description = 'Updates the version in CITATION.cff file' + + outputs.file("CITATION.cff") + + doLast { + def version = project.version.toString() + def today = LocalDate.now().format(DateTimeFormatter.ISO_DATE) + + def cffFile = file('CITATION.cff') + def content = cffFile.text + + // Update or insert version + if (content.contains('version:')) { + content = content.replaceAll(/(?m)^version:\s*.+$/, "version: ${version}") + } else { + content = content + "\nversion: ${version}" + } + + // Update or insert date-released + if (content.contains('date-released:')) { + content = content.replaceAll(/(?m)^date-released:\s*.+$/, "date-released: ${today}") + } else { + content = content + "\ndate-released: ${today}" + } + + cffFile.text = content + println "Updated CITATION.cff to version ${version} and date-released ${today}" + } +} + +// Make sure your custom task runs after a specific task in the release sequence +tasks.named('updateCff') { + mustRunAfter(tasks.named('confirmReleaseVersion')) +} + +// Ensure subsequent tasks in the release sequence run after your custom task +tasks.named('checkSnapshotDependencies') { + dependsOn('updateCff') +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ca025c83..ff23a68d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/edu/kit/datamanager/ro_crate/Crate.java b/src/main/java/edu/kit/datamanager/ro_crate/Crate.java index aff1b6fe..782ff601 100644 --- a/src/main/java/edu/kit/datamanager/ro_crate/Crate.java +++ b/src/main/java/edu/kit/datamanager/ro_crate/Crate.java @@ -22,6 +22,28 @@ */ public interface Crate { + /** + * Mark the crate as imported, i.e. it has been read from a file + * or is for other reasons not considered a new crate. + *

+ * This is useful mostly for readers to indicate this in case + * the crate may not have any provenance information yet and + * should still be recognized as an imported crate. + * + * @return this crate, for convenience. + */ + Crate markAsImported(); + + /** + * Check if the crate is marked as imported. + *

+ * If true, it indicates that the crate has been read from a file + * or is for other reasons not considered a new crate. + * + * @return true if the crate is marked as imported, false otherwise. + */ + boolean isImported(); + /** * Read version from the crate descriptor and return it as a class * representation. diff --git a/src/main/java/edu/kit/datamanager/ro_crate/RoCrate.java b/src/main/java/edu/kit/datamanager/ro_crate/RoCrate.java index 356b0159..8ebae01e 100644 --- a/src/main/java/edu/kit/datamanager/ro_crate/RoCrate.java +++ b/src/main/java/edu/kit/datamanager/ro_crate/RoCrate.java @@ -50,6 +50,24 @@ public class RoCrate implements Crate { protected Collection untrackedFiles; + /** + * Indicates whether this crate has been imported from an external source. + * This is used to determine if ro-crate-java should add a CreateAction + * or an UpdateAction in the provenance on export. + */ + protected boolean isImported = false; + + @Override + public RoCrate markAsImported() { + this.isImported = true; + return this; + } + + @Override + public boolean isImported() { + return this.isImported; + } + @Override public CratePreview getPreview() { return this.roCratePreview; @@ -375,12 +393,14 @@ public RoCrateBuilder addDescription(String description) { * @return returns the builder for further usage. */ public RoCrateBuilder addDataEntity(DataEntity dataEntity) { + this.metadataContext.checkEntity(dataEntity); this.payload.addDataEntity(dataEntity); this.rootDataEntity.addToHasPart(dataEntity.getId()); return this; } public RoCrateBuilder addContextualEntity(ContextualEntity contextualEntity) { + this.metadataContext.checkEntity(contextualEntity); this.payload.addContextualEntity(contextualEntity); return this; } diff --git a/src/main/java/edu/kit/datamanager/ro_crate/context/RoCrateMetadataContext.java b/src/main/java/edu/kit/datamanager/ro_crate/context/RoCrateMetadataContext.java index cce6a7e9..d76cf86b 100644 --- a/src/main/java/edu/kit/datamanager/ro_crate/context/RoCrateMetadataContext.java +++ b/src/main/java/edu/kit/datamanager/ro_crate/context/RoCrateMetadataContext.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.util.*; import java.util.function.Consumer; +import java.util.function.Function; import edu.kit.datamanager.ro_crate.special.IdentifierUtils; import org.apache.http.client.methods.CloseableHttpResponse; @@ -38,7 +39,7 @@ public class RoCrateMetadataContext implements CrateMetadataContext { protected final HashMap other = new HashMap<>(); /** - * Default constructor for the creation of the default context. + * Default constructor for the creation of the v1.1 default context. */ public RoCrateMetadataContext() { this.addToContextFromUrl(DEFAULT_CONTEXT); @@ -46,6 +47,8 @@ public RoCrateMetadataContext() { /** * Constructor for creating the context from a list of url. + *

+ * Note: Does NOT contain the default context if not explicitly given! * * @param urls the url list with different context. */ @@ -55,6 +58,8 @@ public RoCrateMetadataContext(Collection urls) { /** * Constructor for creating the context from a json object. + *

+ * Note: Does NOT contain the default context if not explicitly given! * * @param context the Json object of the context. */ @@ -83,6 +88,28 @@ public RoCrateMetadataContext(JsonNode context) { } } + /** + * Converts the context into a JSON-LD representation. + *

+ * The resulting JSON structure depends on the content: + * - If there's only one URL and no key-value pairs: {"@context": "url"} + * - If there are multiple URLs and/or key-value pairs: {"@context": ["url1", "url2", {"key1": "value1", "key2": "value2"}]} + *

+ * Example output: + *

+   * {
+   *   "@context": [
+   *     "https://w3id.org/ro/crate/1.1/context",
+   *     {
+   *       "schema": "http://schema.org/",
+   *       "rdfs": "http://www.w3.org/2000/01/rdf-schema#"
+   *     }
+   *   ]
+   * }
+   * 
+ * + * @return an ObjectNode containing the JSON-LD context representation + */ @Override public ObjectNode getContextJsonEntity() { ObjectMapper objectMapper = MyObjectMapper.getMapper(); @@ -106,6 +133,21 @@ public ObjectNode getContextJsonEntity() { return finalNode; } + /** + * Checks if the given entity is valid according to the context. + *

+ * - full URLs in the @type and field names are considered valid without further checks. + * - The "@id" value is treated as a special case, where it refers to the entity's ID. + * - The "@json" type is a linked data built-in type and is always considered valid. + * - If a type or field name is not found in the context, it will print an error message and return false. + * - This method checks both the types in the @type array and the field names in the entity's properties. + * - Prefixes in the context are considered valid if they match the context keys. + * - Suffixes after a valid prefix are considered valid in any case. This is not perfect, + * but it would be hard to handle correctly. + * + * @param entity the entity to check + * @return true if the entity is valid, false otherwise + */ @Override public boolean checkEntity(AbstractEntity entity) { ObjectMapper objectMapper = MyObjectMapper.getMapper(); @@ -117,6 +159,11 @@ public boolean checkEntity(AbstractEntity entity) { entity.getProperties().path("@type"), new TypeReference<>() {} ); + + final Function isFail = checkMeStr -> this.contextMap.get(checkMeStr) == null + && this.contextMap.keySet().stream() + .noneMatch(key -> checkMeStr.startsWith(key + ":")); + // check if the items in the array of types are present in the context for (String s : types) { // special cases: @@ -134,7 +181,7 @@ public boolean checkEntity(AbstractEntity entity) { continue; } - if (this.contextMap.get(s) == null) { + if (isFail.apply(s)) { System.err.println("type " + s + " is missing from the context!"); return false; } @@ -147,7 +194,7 @@ public boolean checkEntity(AbstractEntity entity) { // full URLs are considered fine continue; } - if (this.contextMap.get(s) == null) { + if (isFail.apply(s)) { System.err.println("attribute name " + s + " is missing from context;"); return false; } @@ -155,6 +202,13 @@ public boolean checkEntity(AbstractEntity entity) { return true; } + /** + * Adds a URL to the context. + *

+ * It will try to fetch the context from the URL. + * + * @param url the URL to add + */ @Override public void addToContextFromUrl(String url) { this.urls.add(url); @@ -194,18 +248,31 @@ public void addToContextFromUrl(String url) { })); } + /** + * Adds a key-value pair to the context. + * + * @param key the key to add. It may be a prefix or a term. + * @param value the value to add + */ @Override public void addToContext(String key, String value) { this.contextMap.put(key, value); this.other.put(key, value); } + /** + * @param key the key for the value to retrieve. + * @return the value of the key if it exists in the context, null otherwise. + */ @Override public String getValueOf(String key) { return Optional.ofNullable(this.contextMap.get(key)) .orElseGet(() -> this.other.get(key)); } + /** + * @return the set of all keys in the context. + */ @Override public Set getKeys() { List merged = new ArrayList<>(); @@ -214,6 +281,11 @@ public Set getKeys() { return Set.copyOf(merged); } + /** + * @return a map of all key-value pairs in the context. Note that some pairs may come + * from URLs or a pair may not be available as a context was not successfully resolved + * from a URL. + */ @Override public Map getPairs() { Map merged = new HashMap<>(); @@ -222,13 +294,18 @@ public Map getPairs() { return Map.copyOf(merged); } - + /** + * @param key the key to delete from the context. + */ @Override public void deleteValuePairFromContext(String key) { this.contextMap.remove(key); this.other.remove(key); } + /** + * @param url the URL to delete from the context. + */ @Override public void deleteUrlFromContext(String url) { this.urls.remove(url); diff --git a/src/main/java/edu/kit/datamanager/ro_crate/entities/AbstractEntity.java b/src/main/java/edu/kit/datamanager/ro_crate/entities/AbstractEntity.java index f0c75763..38afcf4e 100644 --- a/src/main/java/edu/kit/datamanager/ro_crate/entities/AbstractEntity.java +++ b/src/main/java/edu/kit/datamanager/ro_crate/entities/AbstractEntity.java @@ -112,6 +112,19 @@ public JsonNode getProperty(String propertyKey) { return this.properties.get(propertyKey); } + /** + * Returns the value of the property with the given key as a String. + * If the property is not found, it returns null. + * + * @param propertyKey the key of the property. + * @return the value of the property as a String or null if not found. + */ + public String getIdProperty(String propertyKey) { + return Optional.ofNullable(this.properties.get(propertyKey)) + .map(jsonNode -> jsonNode.path("@id").asText(null)) + .orElse(null); + } + @JsonIgnore public String getId() { JsonNode id = this.properties.get("@id"); @@ -241,9 +254,7 @@ private static boolean addProperty(ObjectNode whereToAdd, String key, JsonNode v public void addIdProperty(String name, String id) { if (id == null || id.isBlank()) { return; } mergeIdIntoValue(id, this.properties.get(name)) - .ifPresent(newValue -> { - this.properties.set(name, newValue); - }); + .ifPresent(newValue -> this.properties.set(name, newValue)); this.linkedTo.add(id); this.notifyObservers(); } @@ -356,7 +367,7 @@ private static void checkFormatISO8601(String date) throws IllegalArgumentExcept /** * Adds a property with date time format. The property should match the ISO 8601 * date format. - * + *

* Same as {@link #addProperty(String, String)} but with internal check. * * @param key key of the property (e.g. datePublished) @@ -411,7 +422,7 @@ public T setId(String id) { if (IdentifierUtils.isValidUri(id)) { this.id = id; } else { - this.id = IdentifierUtils.encode(id).get(); + this.id = IdentifierUtils.encode(id).orElse(this.id); } } return self(); @@ -448,7 +459,7 @@ public T addTypes(Collection types) { /** * Adds a property with date time format. The property should match the ISO 8601 * date format. - * + *

* Same as {@link #addProperty(String, String)} but with internal check. * * @param key key of the property (e.g. datePublished) @@ -508,7 +519,7 @@ public T addProperty(String key, boolean value) { /** * ID properties are often used when referencing other entities within * the ROCrate. This method adds automatically such one. - * + *

* Instead of {@code "name": "id" } * this will add {@code "name" : {"@id": "id"} } * diff --git a/src/main/java/edu/kit/datamanager/ro_crate/preview/CratePreview.java b/src/main/java/edu/kit/datamanager/ro_crate/preview/CratePreview.java index 2459f8b5..e966b4af 100644 --- a/src/main/java/edu/kit/datamanager/ro_crate/preview/CratePreview.java +++ b/src/main/java/edu/kit/datamanager/ro_crate/preview/CratePreview.java @@ -40,6 +40,8 @@ default void generate(Crate crate, File targetDir) throws IOException { // as this is usually called in the process of writing a crate // (including preview) new CrateWriter<>(new WriteFolderStrategy().disablePreview()) + // We assume the caller (e.g. a writer) already stored the provenance. + .withAutomaticProvenance(null) .save(crate, targetDir.getAbsolutePath()); this.saveAllToFolder(targetDir); try (var stream = Files.list(targetDir.toPath())) { diff --git a/src/main/java/edu/kit/datamanager/ro_crate/reader/CrateReader.java b/src/main/java/edu/kit/datamanager/ro_crate/reader/CrateReader.java index 5132e44b..271725b5 100644 --- a/src/main/java/edu/kit/datamanager/ro_crate/reader/CrateReader.java +++ b/src/main/java/edu/kit/datamanager/ro_crate/reader/CrateReader.java @@ -97,7 +97,7 @@ public RoCrate readCrate(T location) throws IOException { usedFiles.add(files.toPath().resolve(FILE_METADATA_JSON).toFile().getPath()); usedFiles.add(files.toPath().resolve(FILE_PREVIEW_HTML).toFile().getPath()); usedFiles.add(files.toPath().resolve(FILE_PREVIEW_FILES).toFile().getPath()); - return rebuildCrate(metadataJson, files, usedFiles); + return rebuildCrate(metadataJson, files, usedFiles).markAsImported(); } private RoCrate rebuildCrate(ObjectNode metadataJson, File files, HashSet usedFiles) { diff --git a/src/main/java/edu/kit/datamanager/ro_crate/util/ClasspathPropertiesVersionProvider.java b/src/main/java/edu/kit/datamanager/ro_crate/util/ClasspathPropertiesVersionProvider.java new file mode 100644 index 00000000..812772cd --- /dev/null +++ b/src/main/java/edu/kit/datamanager/ro_crate/util/ClasspathPropertiesVersionProvider.java @@ -0,0 +1,42 @@ +package edu.kit.datamanager.ro_crate.util; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.Properties; + +public class ClasspathPropertiesVersionProvider implements VersionProvider { + public static final String VERSION_PROPERTIES = "version.properties"; + + /** + * Cached version to avoid repeated file/resource reads. + */ + private String cachedVersion = null; + + /** + * Constructs a ClasspathPropertiesVersionProvider that reads the version from a properties file in the classpath. + */ + public ClasspathPropertiesVersionProvider() { + // Lazy initialization - version loaded on first access + } + + @Override + public String getVersion() { + if (cachedVersion != null) { + return cachedVersion; + } + + URL resource = this.getClass().getResource("/" + VERSION_PROPERTIES); + assert resource != null : VERSION_PROPERTIES + " not found in classpath"; + + try (InputStream input = resource.openStream()) { + Properties properties = new Properties(); + properties.load(input); + String version = properties.getProperty("version"); + assert version != null : "Version property not found in " + VERSION_PROPERTIES; + return version.trim(); + } catch (IOException e) { + throw new IllegalStateException("Failed to read version from properties file", e); + } + } +} diff --git a/src/main/java/edu/kit/datamanager/ro_crate/util/Graph.java b/src/main/java/edu/kit/datamanager/ro_crate/util/Graph.java new file mode 100644 index 00000000..45161f16 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/ro_crate/util/Graph.java @@ -0,0 +1,61 @@ +package edu.kit.datamanager.ro_crate.util; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.stream.StreamSupport; + +/** + * Utility class for handling operations on RO-Crate graphs. + * Provides methods to find entities by ID or type within a graph. + *

+ * {@see JsonUtilFunctions}. + */ +public class Graph { + + private Graph() { + // Private constructor to prevent instantiation + } + + /** + * Finds an entity in the graph by its ID. + * + * @param graph The JSON node representing the graph. + * @param id The ID of the entity to find. + * @return The entity as a JsonNode if found, null otherwise. + */ + public static JsonNode findEntityById(JsonNode graph, String id) { + for (JsonNode entity : graph) { + if (entity.has("@id") && entity.get("@id").asText().equals(id)) { + return entity; + } + } + return null; + } + + /** + * Finds an entity in the graph by its type. + * + * @param graph The JSON node representing the graph. + * @param type The type of the entity to find. + * @return The entity as a JsonNode if found, null otherwise. + */ + public static JsonNode findEntityByType(JsonNode graph, String type) { + return StreamSupport.stream(graph.spliterator(), false) + .filter(entity -> entity.path("@type").asText().equals(type)) + .findFirst() + .orElse(null); + } + + /** + * Finds all entities in the graph by their type. + * + * @param graph The JSON node representing the graph. + * @param type The type of the entities to find. + * @return An array of JsonNode containing all entities of the specified type. + */ + public static JsonNode[] findEntitiesByType(JsonNode graph, String type) { + return StreamSupport.stream(graph.spliterator(), false) + .filter(entity -> entity.path("@type").asText().equals(type)) + .toArray(JsonNode[]::new); + } +} diff --git a/src/main/java/edu/kit/datamanager/ro_crate/util/VersionProvider.java b/src/main/java/edu/kit/datamanager/ro_crate/util/VersionProvider.java new file mode 100644 index 00000000..cd5c4ad2 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/ro_crate/util/VersionProvider.java @@ -0,0 +1,10 @@ +package edu.kit.datamanager.ro_crate.util; + +public interface VersionProvider { + /** + * Returns the version of the ro-crate-java library. + * + * @return The version string. + */ + String getVersion(); +} diff --git a/src/main/java/edu/kit/datamanager/ro_crate/writer/CrateWriter.java b/src/main/java/edu/kit/datamanager/ro_crate/writer/CrateWriter.java index caba67f9..d708ddd7 100644 --- a/src/main/java/edu/kit/datamanager/ro_crate/writer/CrateWriter.java +++ b/src/main/java/edu/kit/datamanager/ro_crate/writer/CrateWriter.java @@ -15,11 +15,29 @@ public class CrateWriter { private final GenericWriterStrategy strategy; + protected ProvenanceManager provenanceManager = new ProvenanceManager(); + /** + * Constructs a CrateWriter with a specified strategy for writing crates. + * + * @param strategy the strategy to use for writing crates. + */ public CrateWriter(GenericWriterStrategy strategy) { this.strategy = strategy; } + /** + * Sets the ProvenanceManager to be used for automatic provenance information + * generation when saving the crate. + * + * @param provenanceManager the ProvenanceManager to use. Using null will disable provenance management. + * @return this CrateWriter instance for method chaining. + */ + public CrateWriter withAutomaticProvenance(ProvenanceManager provenanceManager) { + this.provenanceManager = provenanceManager; + return this; + } + /** * This method saves the crate to a destination provided. * @@ -29,6 +47,9 @@ public CrateWriter(GenericWriterStrategy strategy) { public void save(Crate crate, DESTINATION_TYPE destination) throws IOException { Validator defaultValidation = new Validator(new JsonSchemaValidation()); defaultValidation.validate(crate); + if (this.provenanceManager != null) { + this.provenanceManager.addProvenanceInformation(crate); + } this.strategy.save(crate, destination); } } diff --git a/src/main/java/edu/kit/datamanager/ro_crate/writer/ProvenanceManager.java b/src/main/java/edu/kit/datamanager/ro_crate/writer/ProvenanceManager.java new file mode 100644 index 00000000..fd4a2c50 --- /dev/null +++ b/src/main/java/edu/kit/datamanager/ro_crate/writer/ProvenanceManager.java @@ -0,0 +1,138 @@ +package edu.kit.datamanager.ro_crate.writer; + +import edu.kit.datamanager.ro_crate.Crate; +import edu.kit.datamanager.ro_crate.entities.contextual.ContextualEntity; +import edu.kit.datamanager.ro_crate.util.ClasspathPropertiesVersionProvider; +import edu.kit.datamanager.ro_crate.util.VersionProvider; + +import java.time.Instant; + +import static edu.kit.datamanager.ro_crate.entities.contextual.ContextualEntity.ContextualEntityBuilder; + +/** + * Manages provenance information for RO-Crates. + * Handles the creation and updating of ro-crate-java entity and its actions. + */ +public class ProvenanceManager { + /** + * A record to hold the prefix for the ro-crate-java ID. + * This is used to ensure that the ID is consistent across different versions of the library. + * Having a type for the prefix avoids using it by accident as a full ID. + */ + protected record IdPrefix(String prefix) { + /** + * Constructs a String with the given suffix. + * + * @param suffix The suffix to append to the prefix. + * @return A String combining the prefix and suffix, separated by a hyphen. + * Like this: "$prefix-$suffix". + */ + public String withSuffix(String suffix) { + return prefix + "-" + suffix; + } + + @Override + public String toString() { + return prefix; + } + } + + /** + * The prefix for the ro-crate-java ID. + * This is used to identify the ro-crate-java entity in the crate. + */ + protected static final IdPrefix RO_CRATE_JAVA_ID_PREFIX = new IdPrefix("#ro-crate-java"); + + /** + * The VersionProvider used to retrieve the version of ro-crate-java. + * This allows for flexibility in how the version is determined, e.g., from a properties file. + */ + protected VersionProvider versionProvider; + + /** + * Constructs a ProvenanceManager with the default ClasspathPropertiesVersionProvider. + */ + public ProvenanceManager() { + this(new ClasspathPropertiesVersionProvider()); + } + + /** + * Constructs a ProvenanceManager with a specified VersionProvider. + * + * @param versionProvider The VersionProvider to use for retrieving the version of ro-crate-java. + */ + public ProvenanceManager(VersionProvider versionProvider) { + this.versionProvider = versionProvider; + } + + /** + * Returns the full ID for the ro-crate-java entity of this library version + * to be used for an entity describing it. + *

+ * The ID is constructed using the RO_CRATE_JAVA_ID_PREFIX and the version from the VersionProvider. + * + * @return The ID for the ro-crate-java entity. + */ + public String getLibraryId() { + return RO_CRATE_JAVA_ID_PREFIX.withSuffix(versionProvider.getVersion().toLowerCase()); + } + + /** + * Adds provenance information to the given crate. + * This includes creating or updating the ro-crate-java entity and its associated action entity. + * + * @param crate The crate to which provenance information will be added. + */ + public void addProvenanceInformation(Crate crate) { + // Determine if this is the first write + boolean isFirstWrite = crate.getAllContextualEntities().stream().noneMatch( + entity -> entity.getId().startsWith(RO_CRATE_JAVA_ID_PREFIX.toString())) + && !crate.isImported(); + + String libraryId = this.getLibraryId(); + + // Create action entity first + ContextualEntity actionEntity = buildNewActionEntity(isFirstWrite, libraryId); + + // Create or update ro-crate-java entity + ContextualEntity roCrateJavaEntity = buildRoCrateJavaEntity(crate, actionEntity.getId(), libraryId); + + // Add entities to crate + crate.addContextualEntity(roCrateJavaEntity); + crate.addContextualEntity(actionEntity); + } + + protected ContextualEntity buildNewActionEntity(boolean isFirstWrite, String libraryId) { + return new ContextualEntityBuilder() + .addType(isFirstWrite ? "CreateAction" : "UpdateAction") + .addIdProperty("result", "./") + .addProperty("startTime", Instant.now().toString()) + .addIdProperty("agent", libraryId) + .build(); + } + + protected ContextualEntity buildRoCrateJavaEntity( + Crate crate, + String newActionId, + String libraryId + ) { + String version = this.versionProvider.getVersion(); + ContextualEntity self = crate.getAllContextualEntities().stream() + .filter(contextualEntity -> libraryId.equals(contextualEntity.getId())) + .findFirst() + .orElseGet(() -> new ContextualEntityBuilder() + .setId(libraryId) + .addType("SoftwareApplication") + .addProperty("name", "ro-crate-java") + .addProperty("url", "https://github.com/kit-data-manager/ro-crate-java") + .addProperty("version", version) + .addProperty("softwareVersion", version) + .addProperty("license", "Apache-2.0") + .addProperty("description", "A Java library for creating and manipulating RO-Crates") + .addIdProperty("Action", newActionId) + .build() + ); + self.addIdProperty("Action", newActionId); + return self; + } +} diff --git a/src/test/java/edu/kit/datamanager/ro_crate/context/ContextTest.java b/src/test/java/edu/kit/datamanager/ro_crate/context/RoCrateMetadataContextTest.java similarity index 88% rename from src/test/java/edu/kit/datamanager/ro_crate/context/ContextTest.java rename to src/test/java/edu/kit/datamanager/ro_crate/context/RoCrateMetadataContextTest.java index 4995f272..1fb7b574 100644 --- a/src/test/java/edu/kit/datamanager/ro_crate/context/ContextTest.java +++ b/src/test/java/edu/kit/datamanager/ro_crate/context/RoCrateMetadataContextTest.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import edu.kit.datamanager.ro_crate.HelpFunctions; +import edu.kit.datamanager.ro_crate.RoCrate; import edu.kit.datamanager.ro_crate.entities.AbstractEntity; import edu.kit.datamanager.ro_crate.entities.contextual.ContextualEntity; import edu.kit.datamanager.ro_crate.entities.data.DataEntity; @@ -20,7 +21,7 @@ import static org.junit.jupiter.api.Assertions.*; -public class ContextTest { +public class RoCrateMetadataContextTest { RoCrateMetadataContext context; RoCrateMetadataContext complexContext; @@ -32,7 +33,7 @@ void initContext() throws IOException { final String crateManifestPath = "/crates/extendedContextExample/ro-crate-metadata.json"; ObjectMapper objectMapper = MyObjectMapper.getMapper(); - JsonNode jsonNode = objectMapper.readTree(ContextTest.class.getResourceAsStream(crateManifestPath)); + JsonNode jsonNode = objectMapper.readTree(RoCrateMetadataContextTest.class.getResourceAsStream(crateManifestPath)); this.complexContext = new RoCrateMetadataContext(jsonNode.get("@context")); } @@ -98,8 +99,8 @@ void creationFromPairsJsonTest() { var objectMapper = MyObjectMapper.getMapper(); ObjectNode rawContext = objectMapper.createObjectNode(); - rawContext.put("house", "www.example.con/house"); - rawContext.put("road", "www.example.con/road"); + rawContext.put("house", "www.example.com/house"); + rawContext.put("road", "www.example.com/road"); ObjectNode rawCrate = objectMapper.createObjectNode(); rawCrate.set("@context", rawContext); @@ -107,6 +108,13 @@ void creationFromPairsJsonTest() { assertNotNull(newContext); HelpFunctions.compare(newContext.getContextJsonEntity(), rawCrate, true); + + var entityWithTerms = new ContextualEntity.ContextualEntityBuilder() + .setId("dkfaj") + .addType("house") + .addType("road") + .build(); + assertTrue(newContext.checkEntity(entityWithTerms)); } @Test @@ -278,4 +286,21 @@ void testReadPairs() { // prove immutability assertThrows(UnsupportedOperationException.class, () -> given.put("newKey", "newValue")); } + + @Test + void checkEntity_withDefinedPrefixedType_succeeds() throws IOException { + // assume we read a crate just for demonstration + RoCrate crate = new RoCrate(); + // and we extend the context + RoCrateMetadataContext context = new RoCrateMetadataContext(); + context.addToContext("rdfs", "https://www.w3.org/2000/01/rdf-schema#"); + crate.setMetadataContext(context); + // then we use the new context + DataEntity entity = new DataEntity.DataEntityBuilder() + .addType("rdfs:Property") + .build(); + crate.addDataEntity(entity); + // Then we expect this to work + assertTrue(context.checkEntity(entity)); + } } diff --git a/src/test/java/edu/kit/datamanager/ro_crate/reader/CommonReaderTest.java b/src/test/java/edu/kit/datamanager/ro_crate/reader/CommonReaderTest.java index 1e162a1c..196fe97e 100644 --- a/src/test/java/edu/kit/datamanager/ro_crate/reader/CommonReaderTest.java +++ b/src/test/java/edu/kit/datamanager/ro_crate/reader/CommonReaderTest.java @@ -127,8 +127,12 @@ default void TestWithFileWithLocation(@TempDir Path temp) throws IOException { { // write raw crate and imported crate to two different directories CrateWriter writer = Writers.newFolderWriter(); - writer.save(rawCrate, rawCrateTarget.toString()); - writer.save(importedCrate, importedCrateTarget.toString()); + writer + .withAutomaticProvenance(null) + .save(rawCrate, rawCrateTarget.toString()); + writer + .withAutomaticProvenance(null) + .save(importedCrate, importedCrateTarget.toString()); } assertTrue(HelpFunctions.compareTwoDir(rawCrateTarget.toFile(), importedCrateTarget.toFile())); diff --git a/src/test/java/edu/kit/datamanager/ro_crate/reader/FolderReaderTest.java b/src/test/java/edu/kit/datamanager/ro_crate/reader/FolderReaderTest.java index 83bf4f78..eaee1b46 100644 --- a/src/test/java/edu/kit/datamanager/ro_crate/reader/FolderReaderTest.java +++ b/src/test/java/edu/kit/datamanager/ro_crate/reader/FolderReaderTest.java @@ -21,7 +21,9 @@ class FolderReaderTest implements CommonReaderTest { @Override public void saveCrate(Crate crate, Path target) throws IOException { - Writers.newFolderWriter().save(crate, target.toAbsolutePath().toString()); + Writers.newFolderWriter() + .withAutomaticProvenance(null) + .save(crate, target.toAbsolutePath().toString()); assertTrue(target.toFile().isDirectory()); } diff --git a/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipReaderTest.java b/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipReaderTest.java index 35b168e1..b6ac3ea4 100644 --- a/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipReaderTest.java +++ b/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipReaderTest.java @@ -14,7 +14,9 @@ class ZipReaderTest implements { @Override public void saveCrate(Crate crate, Path target) throws IOException { - Writers.newZipPathWriter().save(crate, target.toAbsolutePath().toString()); + Writers.newZipPathWriter() + .withAutomaticProvenance(null) + .save(crate, target.toAbsolutePath().toString()); assertTrue(target.toFile().isFile()); } diff --git a/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipStreamReaderTest.java b/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipStreamReaderTest.java index 11b82d12..7cf10b32 100644 --- a/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipStreamReaderTest.java +++ b/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipStreamReaderTest.java @@ -33,7 +33,9 @@ public void saveCrate(Crate crate, Path target) throws IOException { try ( FileOutputStream fos = new FileOutputStream(target_file) ) { - Writers.newZipStreamWriter().save(crate, fos); + Writers.newZipStreamWriter() + .withAutomaticProvenance(null) + .save(crate, fos); } assertTrue(target_file.isFile()); } diff --git a/src/test/java/edu/kit/datamanager/ro_crate/writer/FolderWriterTest.java b/src/test/java/edu/kit/datamanager/ro_crate/writer/FolderWriterTest.java index 0c187029..b6181801 100644 --- a/src/test/java/edu/kit/datamanager/ro_crate/writer/FolderWriterTest.java +++ b/src/test/java/edu/kit/datamanager/ro_crate/writer/FolderWriterTest.java @@ -16,6 +16,7 @@ class FolderWriterTest implements CommonWriterTest { @Override public void saveCrate(Crate crate, Path target) throws IOException { Writers.newFolderWriter() + .withAutomaticProvenance(null) .save(crate, target.toAbsolutePath().toString()); } diff --git a/src/test/java/edu/kit/datamanager/ro_crate/writer/ProvenanceManagerTest.java b/src/test/java/edu/kit/datamanager/ro_crate/writer/ProvenanceManagerTest.java new file mode 100644 index 00000000..7e26355a --- /dev/null +++ b/src/test/java/edu/kit/datamanager/ro_crate/writer/ProvenanceManagerTest.java @@ -0,0 +1,182 @@ +package edu.kit.datamanager.ro_crate.writer; + +import edu.kit.datamanager.ro_crate.Crate; +import edu.kit.datamanager.ro_crate.RoCrate; +import edu.kit.datamanager.ro_crate.entities.contextual.ContextualEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ProvenanceManagerTest { + public final String OLD_VERSION = "1.0.0"; + private final ProvenanceManager OLD_PROV_MANAGER = new ProvenanceManager(() -> OLD_VERSION); + private final String OLD_LIBRARY_ID = OLD_PROV_MANAGER.getLibraryId(); + + public final String NEW_VERSION = "2.5.3"; + private final ProvenanceManager NEW_PROV_MANAGER = new ProvenanceManager(() -> NEW_VERSION); + private final String NEW_LIBRARY_ID = NEW_PROV_MANAGER.getLibraryId(); + + @Test + void should_CreateInitialEntities_WithCorrectVersion() { + // Given + Crate crate = new RoCrate.RoCrateBuilder().build(); + + // When + OLD_PROV_MANAGER.addProvenanceInformation(crate); + + // Then + var entities = crate.getAllContextualEntities(); + assertEquals(2, entities.size(), "Should have created two entities"); + + // Find ro-crate-java entity + var roCrateJavaEntity = entities.stream() + .filter(e -> e.getId().equals(OLD_LIBRARY_ID)) + .findFirst() + .orElseThrow(); + + assertEquals(OLD_VERSION, roCrateJavaEntity.getProperty("version").asText()); + assertEquals(OLD_VERSION, roCrateJavaEntity.getProperty("softwareVersion").asText()); + + // Find CreateAction and verify it points to correct version + var createAction = entities.stream() + .filter(e -> e.getTypes().contains("CreateAction")) + .findFirst() + .orElseThrow(); + + assertEquals(OLD_LIBRARY_ID, createAction.getIdProperty("agent")); + } + + @Test + void should_CreateDifferentEntities_WhenDifferentVersionsModifyCrate() { + // Given + Crate crate = new RoCrate.RoCrateBuilder().build(); + + // When creating with old version + OLD_PROV_MANAGER.addProvenanceInformation(crate); + + // And modifying with new version + NEW_PROV_MANAGER.addProvenanceInformation(crate); + + // Then + var entities = crate.getAllContextualEntities(); + assertEquals(4, entities.size(), "Should have four entities (2 ro-crate-java + CreateAction + UpdateAction)"); + + // Verify both version entities exist + var oldVersionEntity = entities.stream() + .filter(e -> e.getId().equals(OLD_LIBRARY_ID)) + .findFirst() + .orElseThrow(); + var newVersionEntity = entities.stream() + .filter(e -> e.getId().equals(NEW_LIBRARY_ID)) + .findFirst() + .orElseThrow(); + + assertEquals(OLD_VERSION, oldVersionEntity.getProperty("version").asText()); + assertEquals(NEW_VERSION, newVersionEntity.getProperty("version").asText()); + + // Verify actions point to correct versions + var createAction = entities.stream() + .filter(e -> e.getTypes().contains("CreateAction")) + .findFirst() + .orElseThrow(); + var updateAction = entities.stream() + .filter(e -> e.getTypes().contains("UpdateAction")) + .findFirst() + .orElseThrow(); + + assertEquals(OLD_LIBRARY_ID, createAction.getIdProperty("agent"), + "CreateAction should point to old version"); + assertEquals(NEW_LIBRARY_ID, updateAction.getIdProperty("agent"), + "UpdateAction should point to new version"); + } + + @Test + void should_ReuseExistingVersionEntity_WhenSameVersionModifiesCrateMultipleTimes() { + // Given + Crate crate = new RoCrate.RoCrateBuilder().build(); + + // When modifying multiple times with same version + OLD_PROV_MANAGER.addProvenanceInformation(crate); + OLD_PROV_MANAGER.addProvenanceInformation(crate); + OLD_PROV_MANAGER.addProvenanceInformation(crate); + + // Then + var entities = crate.getAllContextualEntities(); + + // Should have one ro-crate-java entity and three actions + long roCrateJavaCount = entities.stream() + .filter(e -> e.getId().startsWith(ProvenanceManager.RO_CRATE_JAVA_ID_PREFIX.toString())) + .count(); + assertEquals(1, roCrateJavaCount, "Should have only one ro-crate-java entity"); + + var actions = entities.stream() + .filter(e -> e.getTypes().contains("CreateAction") || e.getTypes().contains("UpdateAction")) + .toList(); + assertEquals(3, actions.size(), "Should have three actions"); + + // All actions should point to the same version entity + for (ContextualEntity action : actions) { + assertEquals(OLD_LIBRARY_ID, action.getIdProperty("agent"), + "All actions should point to the same version entity"); + } + } + + @Test + void should_PreserveVersionSpecificMetadata_WhenModifying() { + // Given + Crate crate = new RoCrate.RoCrateBuilder().build(); + + // When creating with old version + new ProvenanceManager(() -> OLD_VERSION).addProvenanceInformation(crate); + + // And modifying with new version + new ProvenanceManager(() -> NEW_VERSION).addProvenanceInformation(crate); + + // And modifying again with old version + new ProvenanceManager(() -> OLD_VERSION).addProvenanceInformation(crate); + + // Then + var entities = crate.getAllContextualEntities(); + + // Should have exactly two ro-crate-java entities + var roCrateJavaEntities = entities.stream() + .filter(e -> e.getId().startsWith(ProvenanceManager.RO_CRATE_JAVA_ID_PREFIX.toString())) + .toList(); + assertEquals(2, roCrateJavaEntities.size(), "Should have exactly two ro-crate-java entities"); + + // Each entity should maintain its complete metadata + for (ContextualEntity entity : roCrateJavaEntities) { + assertNotNull(entity.getProperty("name"), "Should have name"); + assertNotNull(entity.getProperty("url"), "Should have url"); + assertNotNull(entity.getProperty("license"), "Should have license"); + assertEquals(entity.getProperty("version"), + entity.getProperty("softwareVersion"), + "version and softwareVersion should match"); + } + + // Actions should point to appropriate versions + var actions = entities.stream() + .filter(e -> e.getTypes().contains("CreateAction") || e.getTypes().contains("UpdateAction")) + .toList(); + assertEquals(3, actions.size(), "Should have three actions"); + + // First action (CreateAction) should point to old version + var createAction = actions.stream() + .filter(e -> e.getTypes().contains("CreateAction")) + .findFirst() + .orElseThrow(); + assertEquals(OLD_LIBRARY_ID, createAction.getIdProperty("agent"), + "CreateAction should point to old version"); + + // Update actions should point to respective versions + var updateActions = actions.stream() + .filter(e -> e.getTypes().contains("UpdateAction")) + .toList(); + assertEquals(2, updateActions.size(), "Should have two update actions"); + + assertTrue(updateActions.stream() + .map(e -> e.getIdProperty("agent")) + .allMatch(id -> id.equals(OLD_LIBRARY_ID) || id.equals(NEW_LIBRARY_ID)), + "Update actions should point to either old or new version"); + } +} \ No newline at end of file diff --git a/src/test/java/edu/kit/datamanager/ro_crate/writer/RoCrateMetadataGenerationTest.java b/src/test/java/edu/kit/datamanager/ro_crate/writer/RoCrateMetadataGenerationTest.java new file mode 100644 index 00000000..3c88ef07 --- /dev/null +++ b/src/test/java/edu/kit/datamanager/ro_crate/writer/RoCrateMetadataGenerationTest.java @@ -0,0 +1,406 @@ +package edu.kit.datamanager.ro_crate.writer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import edu.kit.datamanager.ro_crate.RoCrate; +import edu.kit.datamanager.ro_crate.HelpFunctions; +import edu.kit.datamanager.ro_crate.reader.Readers; +import edu.kit.datamanager.ro_crate.validation.JsonSchemaValidation; +import edu.kit.datamanager.ro_crate.validation.Validator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static edu.kit.datamanager.ro_crate.util.Graph.*; +import static org.junit.jupiter.api.Assertions.*; + +class RoCrateMetadataGenerationTest { + + private final String currentVersionId = new ProvenanceManager().getLibraryId(); + + private final ObjectMapper objectMapper = new ObjectMapper(); + private Validator validator; + + @BeforeEach + void setUp() { + validator = new Validator(new JsonSchemaValidation()); + } + + private void validateCrate(RoCrate crate) { + assertTrue(validator.validate(crate), + "Crate should validate against the JSON schema"); + } + + @Test + void should_ContainRoCrateJavaEntities_When_WritingEmptyCrate(@TempDir Path tempDir) throws IOException { + // Create and write crate + RoCrate crate = new RoCrate.RoCrateBuilder().build(); + validateCrate(crate); + + Path outputPath = tempDir.resolve("test-crate"); + Writers.newFolderWriter().save(crate, outputPath.toString()); + + // Re-read the crate to verify it's still valid after writing + RoCrate readCrate = Readers.newFolderReader().readCrate(outputPath.toString()); + validateCrate(readCrate); + + // Read and print metadata for debugging + String metadata = Files.readString(outputPath.resolve("ro-crate-metadata.json")); + HelpFunctions.prettyPrintJsonString(metadata); + + // Parse metadata file + JsonNode rootNode = objectMapper.readTree(metadata); + JsonNode graph = rootNode.get("@graph"); + + // Find ro-crate-java entity + JsonNode roCrateJavaEntity = findEntityById(graph, this.currentVersionId); + assertNotNull(roCrateJavaEntity, "ro-crate-java entity should exist"); + assertEquals("SoftwareApplication", roCrateJavaEntity.get("@type").asText(), + "ro-crate-java should be of type SoftwareApplication"); + + // Find CreateAction entity + JsonNode createActionEntity = findEntityByType(graph, "CreateAction"); + assertNotNull(createActionEntity, "CreateAction entity should exist"); + assertNotNull(createActionEntity.get("startTime"), "CreateAction should have startTime"); + assertEquals(this.currentVersionId, createActionEntity.get("agent").get("@id").asText(), + "CreateAction should reference ro-crate-java as agent"); + } + + @Test + void should_HaveRequiredPropertiesInRoCrateJavaEntity_When_WritingCrate(@TempDir Path tempDir) throws IOException { + // Create and write crate + RoCrate crate = new RoCrate.RoCrateBuilder().build(); + validateCrate(crate); + + Path outputPath = tempDir.resolve("test-crate"); + Writers.newFolderWriter().save(crate, outputPath.toString()); + + RoCrate readCrate = Readers.newFolderReader().readCrate(outputPath.toString()); + validateCrate(readCrate); + + // Read and print metadata for debugging + String metadata = Files.readString(outputPath.resolve("ro-crate-metadata.json")); + HelpFunctions.prettyPrintJsonString(metadata); + + // Parse metadata file + JsonNode rootNode = objectMapper.readTree(metadata); + JsonNode roCrateJavaEntity = findEntityById(rootNode.get("@graph"), this.currentVersionId); + + assertNotNull(roCrateJavaEntity, "ro-crate-java entity should exist"); + assertEquals("ro-crate-java", roCrateJavaEntity.get("name").asText(), + "should have correct name"); + assertEquals("https://github.com/kit-data-manager/ro-crate-java", + roCrateJavaEntity.get("url").asText(), + "should have correct repository URL"); + assertNotNull(roCrateJavaEntity.get("version"), + "should have version property"); + } + + @Test + void should_HaveBidirectionalRelation_Between_RoCrateJavaAndItsAction(@TempDir Path tempDir) throws IOException { + // Create and write crate + RoCrate crate = new RoCrate.RoCrateBuilder().build(); + validateCrate(crate); + + Path outputPath = tempDir.resolve("test-crate"); + Writers.newFolderWriter().save(crate, outputPath.toString()); + + RoCrate readCrate = Readers.newFolderReader().readCrate(outputPath.toString()); + validateCrate(readCrate); + + // Read and print metadata for debugging + String metadata = Files.readString(outputPath.resolve("ro-crate-metadata.json")); + HelpFunctions.prettyPrintJsonString(metadata); + + // Parse metadata file + JsonNode rootNode = objectMapper.readTree(metadata); + JsonNode graph = rootNode.get("@graph"); + + // Get both entities + JsonNode roCrateJavaEntity = findEntityById(graph, this.currentVersionId); + JsonNode createActionEntity = findEntityByType(graph, "CreateAction"); + + assertNotNull(roCrateJavaEntity, "ro-crate-java entity should exist"); + assertNotNull(createActionEntity, "CreateAction entity should exist"); + + // Test CreateAction -> ro-crate-java reference + JsonNode agentRef = createActionEntity.get("agent"); + assertNotNull(agentRef, "CreateAction should have agent property"); + assertEquals(this.currentVersionId, agentRef.get("@id").asText(), + "CreateAction's agent should reference ro-crate-java"); + + // Test ro-crate-java -> CreateAction reference + JsonNode actionRef = roCrateJavaEntity.get("Action"); + assertNotNull(actionRef, "ro-crate-java should have action property"); + assertEquals(createActionEntity.get("@id").asText(), actionRef.get("@id").asText(), + "ro-crate-java's action should reference the CreateAction"); + } + + @Test + void should_AccumulateActions_When_WritingMultipleTimes(@TempDir Path tempDir) throws IOException, InterruptedException { + // Create and write crate first time + RoCrate crate = new RoCrate.RoCrateBuilder().build(); + validateCrate(crate); + + Path outputPath = tempDir.resolve("test-crate"); + Writers.newFolderWriter().save(crate, outputPath.toString()); + validateCrate(Readers.newFolderReader().readCrate(outputPath.toString())); + Thread.sleep(10); + + // Write same crate two more times to simulate updates + Writers.newFolderWriter().save(crate, outputPath.toString()); + Thread.sleep(10); + Writers.newFolderWriter().save(crate, outputPath.toString()); + validateCrate(Readers.newFolderReader().readCrate(outputPath.toString())); + Thread.sleep(10); + + // Read and print metadata for debugging + String metadata = Files.readString(outputPath.resolve("ro-crate-metadata.json")); + HelpFunctions.prettyPrintJsonString(metadata); + + // Parse metadata file + JsonNode rootNode = objectMapper.readTree(metadata); + JsonNode graph = rootNode.get("@graph"); + + // Get ro-crate-java entity + JsonNode roCrateJavaEntity = findEntityById(graph, this.currentVersionId); + assertNotNull(roCrateJavaEntity, "ro-crate-java entity should exist"); + + // Verify actions array exists and has three entries + assertTrue(roCrateJavaEntity.get("Action").isArray(), + "ro-crate-java should have an array of actions"); + assertEquals(3, roCrateJavaEntity.get("Action").size(), + "should have three actions after three writes"); + + // Find all action entities + JsonNode createAction = findEntityByType(graph, "CreateAction"); + assertNotNull(createAction, "should have one CreateAction"); + + JsonNode[] createActions = findEntitiesByType(graph, "CreateAction"); + assertEquals(1, createActions.length, "should have exactly one CreateAction"); + + JsonNode[] updateActions = findEntitiesByType(graph, "UpdateAction"); + assertEquals(2, updateActions.length, "should have two UpdateActions"); + + // Verify CreateAction properties + assertNotNull(createAction.get("startTime"), "CreateAction should have startTime"); + assertEquals(this.currentVersionId, createAction.get("agent").get("@id").asText(), + "CreateAction should reference ro-crate-java as agent"); + + // Verify UpdateAction properties + for (JsonNode updateAction : updateActions) { + assertNotNull(updateAction.get("startTime"), + "UpdateAction should have startTime"); + assertEquals(this.currentVersionId, updateAction.get("agent").get("@id").asText(), + "UpdateAction should reference ro-crate-java as agent"); + } + + // Verify chronological order of timestamps + String createTime = createAction.get("startTime").asText(); + String updateTime1 = updateActions[0].get("startTime").asText(); + String updateTime2 = updateActions[1].get("startTime").asText(); + // The order of updates is not the order in the graph. + // But we can check that creation happened before the updates: + assertTrue(createTime.compareTo(updateTime1) < 0, + "First update should be after creation"); + assertTrue(createTime.compareTo(updateTime2) < 0, + "Second update should be after creation"); + } + + @Test + void should_HaveValidVersionFormat_When_WritingCrate(@TempDir Path tempDir) throws IOException { + // Create and write crate + RoCrate crate = new RoCrate.RoCrateBuilder().build(); + validateCrate(crate); + + Path outputPath = tempDir.resolve("test-crate"); + Writers.newFolderWriter().save(crate, outputPath.toString()); + validateCrate(Readers.newFolderReader().readCrate(outputPath.toString())); + + // Read and print metadata for debugging + String metadata = Files.readString(outputPath.resolve("ro-crate-metadata.json")); + HelpFunctions.prettyPrintJsonString(metadata); + + // Parse metadata file + JsonNode rootNode = objectMapper.readTree(metadata); + JsonNode roCrateJavaEntity = findEntityById(rootNode.get("@graph"), this.currentVersionId); + + // Version format validation + @SuppressWarnings("DataFlowIssue") + String version = roCrateJavaEntity.get("version").asText(); + + // Semantic versioning regex pattern that allows: + // - Required: major.minor.patch (e.g., 1.2.3) + // - Optional: pre-release identifier (e.g., -rc1, -RC1, -beta.1, -SNAPSHOT) + // - Optional: build metadata (e.g., +build.123) + String semverPattern = "(?i)^\\d+\\.\\d+\\.\\d+(?:-(?:rc\\d+|alpha|beta|snapshot)(?:\\.\\d+)?)?(?:\\+[0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*)?$"; + + assertTrue(version.matches(semverPattern), + String.format("Version '%s' should match semantic versioning format: major.minor.patch[-prerelease][+build]%n" + + "Examples: 1.2.3, 1.2.3-rc1, 1.2.3-SNAPSHOT, 1.2.3-beta.1, 1.2.3+build.123", version)); + } + + @SuppressWarnings("DataFlowIssue") + @Test + void should_HaveCompleteMetadata_When_WritingCrate(@TempDir Path tempDir) throws IOException { + // Create and write crate + RoCrate crate = new RoCrate.RoCrateBuilder().build(); + validateCrate(crate); + + Path outputPath = tempDir.resolve("test-crate"); + Writers.newFolderWriter().save(crate, outputPath.toString()); + validateCrate(Readers.newFolderReader().readCrate(outputPath.toString())); + + // Read and print metadata for debugging + String metadata = Files.readString(outputPath.resolve("ro-crate-metadata.json")); + HelpFunctions.prettyPrintJsonString(metadata); + + // Parse metadata file + JsonNode rootNode = objectMapper.readTree(metadata); + JsonNode roCrateJavaEntity = findEntityById(rootNode.get("@graph"), this.currentVersionId); + + // Required properties with specific values + assertEquals("ro-crate-java", roCrateJavaEntity.get("name").asText(), + "should have correct name"); + assertEquals("https://github.com/kit-data-manager/ro-crate-java", + roCrateJavaEntity.get("url").asText(), + "should have correct repository URL"); + assertEquals("SoftwareApplication", roCrateJavaEntity.get("@type").asText(), + "should have correct type"); + + // Optional but recommended properties + assertNotNull(roCrateJavaEntity.get("description"), + "should have a description"); + assertFalse(roCrateJavaEntity.get("description").asText().isEmpty(), + "description should not be empty"); + + assertNotNull(roCrateJavaEntity.get("license"), + "should have a license"); + assertTrue(roCrateJavaEntity.has("softwareVersion"), + "should have softwareVersion as an alias for version"); + assertEquals(roCrateJavaEntity.get("version").asText(), + roCrateJavaEntity.get("softwareVersion").asText(), + "version and softwareVersion should match"); + } + + @Test + void should_AddProvenanceInfo_When_ModifyingExistingCrateWithoutProvenance(@TempDir Path tempDir) throws IOException { + // First create a crate without provenance information + RoCrate originalCrate = new RoCrate.RoCrateBuilder().build(); + validateCrate(originalCrate); + + Path outputPath = tempDir.resolve("test-crate"); + + // Use writer with disabled provenance (not implemented yet) + Writers.newFolderWriter() + .withAutomaticProvenance(null) + .save(originalCrate, outputPath.toString()); + + // Verify the original crate has no provenance information + String originalMetadata = Files.readString(outputPath.resolve("ro-crate-metadata.json")); + HelpFunctions.prettyPrintJsonString(originalMetadata); + + JsonNode originalRoot = objectMapper.readTree(originalMetadata); + JsonNode originalGraph = originalRoot.get("@graph"); + assertNull(findEntityById(originalGraph, this.currentVersionId), + "Original crate should not have ro-crate-java entity"); + assertNull(findEntityByType(originalGraph, "CreateAction"), + "Original crate should not have CreateAction"); + assertNull(findEntityByType(originalGraph, "UpdateAction"), + "Original crate should not have UpdateAction"); + + // Now read and modify the crate + RoCrate modifiedCrate = Readers.newFolderReader().readCrate(outputPath.toString()); + validateCrate(modifiedCrate); + modifiedCrate.getRootDataEntity().addProperty("description", "Modified crate"); + validateCrate(modifiedCrate); + + // Write the modified crate with provenance enabled (default) + Path modifiedPath = tempDir.resolve("modified-crate"); + Writers.newFolderWriter().save(modifiedCrate, modifiedPath.toString()); + + // Read and verify the modified crate's metadata + String modifiedMetadata = Files.readString(modifiedPath.resolve("ro-crate-metadata.json")); + HelpFunctions.prettyPrintJsonString(modifiedMetadata); + + JsonNode modifiedRoot = objectMapper.readTree(modifiedMetadata); + JsonNode modifiedGraph = modifiedRoot.get("@graph"); + + // Verify ro-crate-java entity was added + JsonNode roCrateJavaEntity = findEntityById(modifiedGraph, this.currentVersionId); + assertNotNull(roCrateJavaEntity, "ro-crate-java entity should be added"); + + // Should only have UpdateAction, no CreateAction + assertNull(findEntityByType(modifiedGraph, "CreateAction"), + "Modified crate should not have CreateAction"); + + JsonNode updateAction = findEntityByType(modifiedGraph, "UpdateAction"); + assertNotNull(updateAction, "Should have UpdateAction"); + + // Verify update action properties + assertNotNull(updateAction.get("startTime"), + "UpdateAction should have startTime"); + assertEquals(this.currentVersionId, + updateAction.get("agent").get("@id").asText(), + "UpdateAction should reference ro-crate-java as agent"); + + // Verify ro-crate-java references the action + assertTrue(roCrateJavaEntity.get("Action").isObject(), + "ro-crate-java should have a single reference to an UpdateAction"); + assertEquals(updateAction.get("@id").asText(), + roCrateJavaEntity.get("Action").get("@id").asText(), + "ro-crate-java should reference the UpdateAction"); + } + + @Test + void should_PreserveExistingProvenance_When_ModifyingCrate(@TempDir Path tempDir) throws IOException, InterruptedException { + // First create a crate with normal provenance + RoCrate originalCrate = new RoCrate.RoCrateBuilder().build(); + validateCrate(originalCrate); + + Path outputPath = tempDir.resolve("test-crate"); + Writers.newFolderWriter().save(originalCrate, outputPath.toString()); + Thread.sleep(10); + + // Now read and modify the crate + RoCrate modifiedCrate = Readers.newFolderReader().readCrate(outputPath.toString()); + validateCrate(modifiedCrate); + modifiedCrate.getRootDataEntity().addProperty("description", "Modified crate"); + validateCrate(modifiedCrate); + + // Write the modified crate + Writers.newFolderWriter().save(modifiedCrate, outputPath.toString()); + + // Read and verify the metadata + String metadata = Files.readString(outputPath.resolve("ro-crate-metadata.json")); + HelpFunctions.prettyPrintJsonString(metadata); + + JsonNode root = objectMapper.readTree(metadata); + JsonNode graph = root.get("@graph"); + + // Should have both CreateAction and UpdateAction + JsonNode createAction = findEntityByType(graph, "CreateAction"); + assertNotNull(createAction, "Original CreateAction should be preserved"); + + JsonNode[] updateActions = findEntitiesByType(graph, "UpdateAction"); + assertEquals(1, updateActions.length, "Should have exactly one UpdateAction"); + + // Verify chronological order + String createTime = createAction.get("startTime").asText(); + String updateTime = updateActions[0].get("startTime").asText(); + assertTrue(createTime.compareTo(updateTime) < 0, + "Update should be after creation"); + + // Verify ro-crate-java entity references both actions + JsonNode roCrateJavaEntity = findEntityById(graph, this.currentVersionId); + //noinspection DataFlowIssue + assertTrue(roCrateJavaEntity.get("Action").isArray(), + "ro-crate-java should have an array of actions"); + assertEquals(2, roCrateJavaEntity.get("Action").size(), + "should have both actions"); + } +} diff --git a/src/test/java/edu/kit/datamanager/ro_crate/writer/RoCrateWriterSpec12Test.java b/src/test/java/edu/kit/datamanager/ro_crate/writer/RoCrateWriterSpec12Test.java index 90161e4d..f3f57ef7 100644 --- a/src/test/java/edu/kit/datamanager/ro_crate/writer/RoCrateWriterSpec12Test.java +++ b/src/test/java/edu/kit/datamanager/ro_crate/writer/RoCrateWriterSpec12Test.java @@ -30,6 +30,7 @@ void writeDoesNotModifyTest(@TempDir Path tempDir) throws IOException, URISyntax Path targetDir = tempDir.resolve("spec12writeUnmodified"); Writers.newFolderWriter() + .withAutomaticProvenance(null) .save(crate, targetDir.toAbsolutePath().toString()); // compare directories diff --git a/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipStreamWriterTest.java b/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipStreamWriterTest.java index 83e311b1..feb87471 100644 --- a/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipStreamWriterTest.java +++ b/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipStreamWriterTest.java @@ -17,7 +17,9 @@ class ZipStreamWriterTest implements @Override public void saveCrate(Crate crate, Path target) throws IOException { try (FileOutputStream stream = new FileOutputStream(target.toFile())) { - Writers.newZipStreamWriter().save(crate, stream); + Writers.newZipStreamWriter() + .withAutomaticProvenance(null) + .save(crate, stream); } } @@ -25,6 +27,7 @@ public void saveCrate(Crate crate, Path target) throws IOException { public void saveCrateElnStyle(Crate crate, Path target) throws IOException { try (FileOutputStream stream = new FileOutputStream(target.toFile())) { new CrateWriter<>(new WriteZipStreamStrategy().usingElnStyle()) + .withAutomaticProvenance(null) .save(crate, stream); } } @@ -33,6 +36,7 @@ public void saveCrateElnStyle(Crate crate, Path target) throws IOException { public void saveCrateSubdirectoryStyle(RoCrate crate, Path target) throws IOException { try (FileOutputStream stream = new FileOutputStream(target.toFile())) { new CrateWriter<>(new WriteZipStreamStrategy().withRootSubdirectory()) + .withAutomaticProvenance(null) .save(crate, stream); } } diff --git a/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipWriterTest.java b/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipWriterTest.java index bfb29c3d..d1352f31 100644 --- a/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipWriterTest.java +++ b/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipWriterTest.java @@ -13,18 +13,21 @@ class ZipWriterTest implements @Override public void saveCrate(Crate crate, Path target) throws IOException { Writers.newZipPathWriter() + .withAutomaticProvenance(null) .save(crate, target.toAbsolutePath().toString()); } @Override public void saveCrateElnStyle(Crate crate, Path target) throws IOException { new CrateWriter<>(new WriteZipStrategy().usingElnStyle()) + .withAutomaticProvenance(null) .save(crate, target.toAbsolutePath().toString()); } @Override public void saveCrateSubdirectoryStyle(RoCrate crate, Path target) throws IOException { new CrateWriter<>(new WriteZipStrategy().withRootSubdirectory()) + .withAutomaticProvenance(null) .save(crate, target.toString()); } }