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
10 changes: 8 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
cmake_minimum_required(VERSION 3.20)
cmake_minimum_required(VERSION 3.24)

project(ro-crate-cpp LANGUAGES CXX)

include(CTest)
include(FetchContent)

add_library(ro-crate-cpp INTERFACE)

Expand All @@ -24,7 +25,12 @@ target_link_libraries(example
)

if(BUILD_TESTING)
find_package(Catch2 3 REQUIRED)
FetchContent_Declare(Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.16.0
FIND_PACKAGE_ARGS 3
)
FetchContent_MakeAvailable(Catch2)

add_executable(ro-crate-cpp_tests
tests/integration.cpp
Expand Down
172 changes: 127 additions & 45 deletions include/ro-crate.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,85 @@

#include <nlohmann/json.hpp>

#include <format>
#include <fstream>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <map>
#include <unordered_map>
#include <vector>

namespace rocrate {

// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------

enum class ErrorCode {
TypesEmpty,
PropertyNameEmpty,
PropertyValueEmpty,
IdPropertyDirectSet,
EntityMissingId,
EntityIdEmpty,
EntityAlreadyExists,
EntityNotFound,
ContextKeyEmpty,
ContextValueEmpty,
FileOpenWriteFailed,
FileOpenReadFailed,
FileParseFailed,
InvalidCrateStructure,
EntityMissingIdField,
EntityMissingTypeField
};

/**
* Returns the message template for the given error code.
* Templates with placeholders are substituted via std::format at the throw site.
*/
constexpr std::string_view error_message(ErrorCode code) {
switch (code) {
case ErrorCode::TypesEmpty:
return "Entity must have at least one type.";
case ErrorCode::PropertyNameEmpty:
return "Property name cannot be empty.";
case ErrorCode::PropertyValueEmpty:
return "Property value cannot be empty.";
case ErrorCode::IdPropertyDirectSet:
return "Cannot set '@id' property directly. "
"Use ROCrate::addEntity to assign an ID.";
case ErrorCode::EntityMissingId:
return "Entity does not have an '@id' property set.";
case ErrorCode::EntityIdEmpty:
return "Entity ID cannot be empty.";
case ErrorCode::EntityAlreadyExists:
return "Entity with id '{}' already exists in the RO-Crate.";
case ErrorCode::EntityNotFound:
return "Entity with id '{}' not found in the RO-Crate.";
case ErrorCode::ContextKeyEmpty:
return "Context key cannot be empty.";
case ErrorCode::ContextValueEmpty:
return "Context value cannot be empty.";
case ErrorCode::FileOpenWriteFailed:
return "Failed to open file for writing: {}";
case ErrorCode::FileOpenReadFailed:
return "Failed to open file for reading: {}";
case ErrorCode::FileParseFailed:
return "Failed to parse JSON from file: {}. Error: {}";
case ErrorCode::InvalidCrateStructure:
return "Invalid RO-Crate structure: missing '@context' or '@graph' fields.";
case ErrorCode::EntityMissingIdField:
return "Entity in RO-Crate JSON is missing '@id' field.";
case ErrorCode::EntityMissingTypeField:
return "Entity in RO-Crate JSON is missing '@type' field.";
}

return "Unknown error";
}

// ---------------------------------------------------------------------------
// Entity
// ---------------------------------------------------------------------------
Expand All @@ -24,7 +94,7 @@ namespace rocrate {
ValueType type;
};

using Properties = std::map<Property, std::vector<PropertyValue>>;
using Properties = std::unordered_map<Property, std::vector<PropertyValue>>;

/**
* Represents an entity in the RO-Crate.
Expand Down Expand Up @@ -87,7 +157,7 @@ namespace rocrate {

// Validate types (reject empty)
if ( types.empty() ) {
throw std::invalid_argument("Entity must have at least one type.");
throw std::invalid_argument(std::string(error_message(ErrorCode::TypesEmpty)));
}

// Set the types of the entity
Expand All @@ -100,12 +170,11 @@ namespace rocrate {
inline void Entity::set(Property property, Value value, ValueType valueType) {
// Validate property and value
if (property.empty())
throw std::invalid_argument("Property name cannot be empty.");
throw std::invalid_argument(std::string(error_message(ErrorCode::PropertyNameEmpty)));
if (value.empty())
throw std::invalid_argument("Property value cannot be empty.");
throw std::invalid_argument(std::string(error_message(ErrorCode::PropertyValueEmpty)));
if (property == "@id")
throw std::invalid_argument("Cannot set '@id' property directly. "
"Use ROCrate::addEntity to assign an ID.");
throw std::invalid_argument(std::string(error_message(ErrorCode::IdPropertyDirectSet)));

// Check property is not duplicate - ignore if so
auto it = properties_->find(property);
Expand All @@ -125,13 +194,13 @@ namespace rocrate {
inline void Entity::set(Property property, const Entity& entity) {
// Validate property name
if (property.empty()) {
throw std::invalid_argument("Property name cannot be empty.");
throw std::invalid_argument(std::string(error_message(ErrorCode::PropertyNameEmpty)));
}

// Check if the entity has an '@id' property set
const auto id = entity.properties_->find("@id");
if (id == entity.properties_->end() || id->second.empty())
throw std::runtime_error("Entity does not have an '@id' property set.");
throw std::runtime_error(std::string(error_message(ErrorCode::EntityMissingId)));

// Add JSON reference to the entity's '@id' to the property in the properties map
set(property, id->second.front().value, ValueType::Reference);
Expand All @@ -140,7 +209,7 @@ namespace rocrate {
inline void Entity::assignId(const std::string& id) {
// Validate id
if (id.empty()) {
throw std::invalid_argument("Entity ID cannot be empty.");
throw std::invalid_argument(std::string(error_message(ErrorCode::EntityIdEmpty)));
}

// Assign the '@id' property to the entity's properties map
Expand All @@ -151,7 +220,7 @@ namespace rocrate {
// RO-Crate
// ---------------------------------------------------------------------------

using EntityRegister = std::map<std::string, Entity>;
using EntityRegister = std::unordered_map<std::string, Entity>;

/**
* Represents an RO-Crate, a structured collection of entities and metadata.
Expand Down Expand Up @@ -215,6 +284,9 @@ namespace rocrate {
void readIn(const std::string& path);

private:
friend void to_json(nlohmann::json& j, const ROCrate& crate);
friend void from_json(const nlohmann::json& j, ROCrate& crate);

nlohmann::json serializeEntity(
const std::string& id,
const Entity& entity
Expand Down Expand Up @@ -261,12 +333,12 @@ namespace rocrate {
inline void ROCrate::addEntity(const std::string& id, Entity& entity) {
// Validate the id (reject empty)
if (id.empty()) {
throw std::invalid_argument("Entity ID cannot be empty.");
throw std::invalid_argument(std::string(error_message(ErrorCode::EntityIdEmpty)));
}

// Check if the entity with the given id already exists in the register
if (entities_.find(id) != entities_.end()) {
throw std::runtime_error("Entity with id '" + id + "' already exists in the RO-Crate.");
if (entities_.contains(id)) {
throw std::runtime_error(std::format(error_message(ErrorCode::EntityAlreadyExists), id));
}

// Add the assigned ID to the Entity itself
Expand All @@ -279,108 +351,118 @@ namespace rocrate {
inline Entity& ROCrate::getEntity(const std::string& id) {
auto it = entities_.find(id);
if (it == entities_.end()) {
throw std::runtime_error("Entity with id '" + id + "' not found in the RO-Crate.");
throw std::runtime_error(std::format(error_message(ErrorCode::EntityNotFound), id));
}
return it->second;
}

inline void ROCrate::addContext(const std::string& key, const std::string& value) {
// Validate the context
if (key.empty()) {
throw std::invalid_argument("Context key cannot be empty.");
throw std::invalid_argument(std::string(error_message(ErrorCode::ContextKeyEmpty)));
}
if (value.empty()) {
throw std::invalid_argument("Context value cannot be empty.");
throw std::invalid_argument(std::string(error_message(ErrorCode::ContextValueEmpty)));
}

// Add the context entry to the RO-Crate's root metadata entity
context_[key] = value;
}

inline void ROCrate::writeOut(const std::string& path) {
nlohmann::json outCrate = *this;


// Write the JSON representation of the RO-Crate to a file
std::ofstream outFile(path);
if (!outFile)
throw std::runtime_error(std::format(error_message(ErrorCode::FileOpenWriteFailed), path));

outFile << outCrate.dump(4); // Pretty print with 4 spaces indentation
}

inline void to_json(nlohmann::json& j, const ROCrate& crate) {
// Create the JSON representation of the RO-Crate
nlohmann::json outCrate = {
j = {
{"@context", "https://w3id.org/ro/crate/1.3/context"},
{"@graph", nlohmann::json::array()}
};

// Add the context entries to the JSON representation
if (!context_.empty()) {
outCrate["@context"] = nlohmann::json::array();
outCrate["@context"].push_back("https://w3id.org/ro/crate/1.3/context");
for (const auto& [key, value] : context_) {
outCrate["@context"].push_back({{key, value}});
if (!crate.context_.empty()) {
j["@context"] = nlohmann::json::array();
j["@context"].push_back("https://w3id.org/ro/crate/1.3/context");
for (const auto& [key, value] : crate.context_) {
j["@context"].push_back({{key, value}});
}
}

// Iterate over the entities, conver to json and append to the graph
for (const auto& [id, entity] : entities_) {
outCrate["@graph"].push_back(serializeEntity(id, entity));
for (const auto& [id, entity] : crate.entities_) {
j["@graph"].push_back(crate.serializeEntity(id, entity));
}

// Write the JSON representation of the RO-Crate to a file
std::ofstream outFile(path);
if (!outFile)
throw std::runtime_error("Failed to open file for writing: " + path);

outFile << outCrate.dump(4); // Pretty print with 4 spaces indentation
}

inline void ROCrate::readIn(const std::string& path) {
// Read the JSON representation of the RO-Crate from a file
std::ifstream inFile(path);
if (!inFile)
throw std::runtime_error("Failed to open file for reading: " + path);
throw std::runtime_error(std::format(error_message(ErrorCode::FileOpenReadFailed), path));

// Check if valid JSON
nlohmann::json inCrate;
try {
inFile >> inCrate;
} catch (const nlohmann::json::parse_error& e) {
throw std::runtime_error("Failed to parse JSON from file: " + path + ". Error: " + e.what());
throw std::runtime_error(std::format(error_message(ErrorCode::FileParseFailed), path, e.what()));
}

// Validate the RO-Crate structure and populate the current instance
from_json(inCrate, *this);
}

inline void from_json(const nlohmann::json& j, ROCrate& crate) {
// Validate the RO-Crate structure
if (!inCrate.contains("@context") || !inCrate.contains("@graph")) {
throw std::runtime_error("Invalid RO-Crate structure in file: " + path);
if (!j.contains("@context") || !j.contains("@graph")) {
throw std::runtime_error(std::string(error_message(ErrorCode::InvalidCrateStructure)));
}

// Upsert the context entries from the JSON representation into the RO-Crate
if (inCrate["@context"].is_array()) {
for (const auto& contextEntry : inCrate["@context"]) {
if (j["@context"].is_array()) {
for (const auto& contextEntry : j["@context"]) {
if (contextEntry.is_object()) {
for (const auto& [key, value] : contextEntry.items()) {
addContext(key, value.get<std::string>());
crate.addContext(key, value.get<std::string>());
}
}
}
}

// Iterate over the entities in the JSON representation and add them to the RO-Crate
for (const auto& entityJson : inCrate["@graph"]) {
for (const auto& entityJson : j["@graph"]) {
// Validate that the entity has an '@id' field
if (!entityJson.contains("@id")) {
throw std::runtime_error("Entity in RO-Crate JSON is missing '@id' field.");
throw std::runtime_error(std::string(error_message(ErrorCode::EntityMissingIdField)));
}
std::string entityId = entityJson["@id"].get<std::string>();

// Get the entity type
if (!entityJson.contains("@type")) {
throw std::runtime_error("Entity in RO-Crate JSON is missing '@type' field.");
throw std::runtime_error(std::string(error_message(ErrorCode::EntityMissingTypeField)));
}
std::string entityType = entityJson["@type"].get<std::string>();

// Try getting the entity from the register, if it exists, otherwise create a new one
Entity entity({"Thing"}); // Default type, will be overridden if entity exists
try {
// Existing entity
entity = getEntity(entityId);
updateProperties(entity, entityJson);
entity = crate.getEntity(entityId);
crate.updateProperties(entity, entityJson);
} catch (const std::runtime_error&) {
// Entity does not exist, create a new one
entity = Entity({entityType});
updateProperties(entity, entityJson);
addEntity(entityId, entity);
crate.updateProperties(entity, entityJson);
crate.addEntity(entityId, entity);
}
}
}
Expand Down