From 7869d44a94ae2e758532064bd22c97859b1a822f Mon Sep 17 00:00:00 2001 From: "chia-lin.lin" Date: Wed, 6 May 2026 22:24:47 +0200 Subject: [PATCH 1/2] Support RO-Crate spec 1.2 Parameterize the metadata version with a `version:` kwarg and an `SUPPORTED_VERSIONS` allow-list, defaulting to 1.2. `@context` and `conformsTo` URLs are derived from the version. Mirrors the pattern in ro -crate-py. Reader is unchanged and remains version-tolerant via the [https://w3id.org/ro/crate/](https://w3id.org/ro/crate/%60) prefix match. Existing callers see no API change; freshly written crates now declare 1.2 by default. --- README.md | 6 +++--- lib/ro_crate/model/crate.rb | 13 +++++++++++-- lib/ro_crate/model/metadata.rb | 28 +++++++++++++++++++++++----- lib/ro_crate/reader.rb | 4 ++-- test/crate_test.rb | 20 ++++++++++++++++++++ 5 files changed, 59 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5c500b8..fef2f2b 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ ![Tests](https://github.com/ResearchObject/ro-crate-ruby/actions/workflows/tests.yml/badge.svg) -This is a WIP gem for creating, manipulating and reading RO-Crates (conforming to version 1.1 of the specification). +This is a WIP gem for creating, manipulating and reading RO-Crates (conforming to version 1.2 of the specification). RO-Crates produced by older versions (1.0, 1.1) of the spec can still be read. -* RO-Crate - https://researchobject.github.io/ro-crate/ -* RO-Crate spec (1.1) - https://researchobject.github.io/ro-crate/1.1/ +* RO-Crate - https://www.researchobject.org/ro-crate/ +* RO-Crate spec (1.2) - https://www.researchobject.org/ro-crate/specification/1.2/ ## Installation diff --git a/lib/ro_crate/model/crate.rb b/lib/ro_crate/model/crate.rb index 0037449..30c336a 100644 --- a/lib/ro_crate/model/crate.rb +++ b/lib/ro_crate/model/crate.rb @@ -21,9 +21,18 @@ def self.format_local_id(id) ## # Initialize an empty RO-Crate. - def initialize(id = IDENTIFIER, properties = {}) + # + # @param id [String] The crate's identifier. + # @param properties [Hash] Initial properties for the root data entity. + # @param version [String] RO-Crate spec version to declare (default: ROCrate::Metadata::DEFAULT_VERSION). + # Must be one of ROCrate::Metadata::SUPPORTED_VERSIONS. + def initialize(id = IDENTIFIER, properties = {}, version: ROCrate::Metadata::DEFAULT_VERSION) + unless ROCrate::Metadata::SUPPORTED_VERSIONS.include?(version) + raise ArgumentError, "Unsupported RO-Crate version: #{version.inspect}. Supported: #{ROCrate::Metadata::SUPPORTED_VERSIONS.join(', ')}" + end @data_entities = Set.new @contextual_entities = Set.new + @metadata_version = version super(self, nil, id, properties) end @@ -168,7 +177,7 @@ def add_data_entity(entity) # # @return [Metadata] def metadata - @metadata ||= ROCrate::Metadata.new(self) + @metadata ||= ROCrate::Metadata.new(self, {}, version: @metadata_version || ROCrate::Metadata::DEFAULT_VERSION) end ## diff --git a/lib/ro_crate/model/metadata.rb b/lib/ro_crate/model/metadata.rb index 8017d01..d15c0eb 100644 --- a/lib/ro_crate/model/metadata.rb +++ b/lib/ro_crate/model/metadata.rb @@ -5,13 +5,31 @@ class Metadata < File IDENTIFIER = 'ro-crate-metadata.json'.freeze IDENTIFIER_1_0 = 'ro-crate-metadata.jsonld'.freeze # 1.0 spec identifier RO_CRATE_BASE = 'https://w3id.org/ro/crate/' - CONTEXT = "#{RO_CRATE_BASE}1.1/context".freeze - SPEC = "#{RO_CRATE_BASE}1.1".freeze - def initialize(crate, properties = {}) + SUPPORTED_VERSIONS = %w[1.0 1.0-DRAFT 1.1 1.1-DRAFT 1.2 1.2-DRAFT].freeze + DEFAULT_VERSION = '1.2'.freeze + + CONTEXT = "#{RO_CRATE_BASE}#{DEFAULT_VERSION}/context".freeze + SPEC = "#{RO_CRATE_BASE}#{DEFAULT_VERSION}".freeze + + attr_reader :version + + def initialize(crate, properties = {}, version: DEFAULT_VERSION) + unless SUPPORTED_VERSIONS.include?(version) + raise ArgumentError, "Unsupported RO-Crate version: #{version.inspect}. Supported: #{SUPPORTED_VERSIONS.join(', ')}" + end + @version = version super(crate, nil, IDENTIFIER, properties) end + def context_url + "#{RO_CRATE_BASE}#{@version}/context" + end + + def spec_url + "#{RO_CRATE_BASE}#{@version}" + end + ## # Generate the crate's `ro-crate-metadata.jsonld`. # @return [String] The rendered JSON-LD as a "prettified" string. @@ -21,7 +39,7 @@ def generate end def context - @context || CONTEXT + @context || context_url end def context= c @@ -39,7 +57,7 @@ def default_properties '@id' => IDENTIFIER, '@type' => 'CreativeWork', 'about' => { '@id' => crate.id }, - 'conformsTo' => { '@id' => SPEC } + 'conformsTo' => { '@id' => spec_url } } end end diff --git a/lib/ro_crate/reader.rb b/lib/ro_crate/reader.rb index 4ebe10a..0cad4ac 100644 --- a/lib/ro_crate/reader.rb +++ b/lib/ro_crate/reader.rb @@ -266,7 +266,7 @@ def self.create_data_entity(crate, entity_class, source, entity_props) ## # Extract the metadata entity from the entity hash, according to the rules defined here: - # https://www.researchobject.org/ro-crate/1.1/root-data-entity.html#finding-the-root-data-entity + # https://www.researchobject.org/ro-crate/specification/1.2/root-data-entity.html#finding-the-root-data-entity # @return [nil, Hash{String => Hash}] A Hash containing (hopefully) one value, the metadata entity's properties # mapped by its @id, or nil if nothing is found. def self.extract_metadata_entity(entities) @@ -294,7 +294,7 @@ def self.extract_preview_entity(entities) ## # Extract the root entity from the entity hash, according to the rules defined here: - # https://www.researchobject.org/ro-crate/1.1/root-data-entity.html#finding-the-root-data-entity + # https://www.researchobject.org/ro-crate/specification/1.2/root-data-entity.html#finding-the-root-data-entity # @return [Hash{String => Hash}] A Hash containing (hopefully) one value, the root entity's properties, # mapped by its @id. def self.extract_root_entity(entities) diff --git a/test/crate_test.rb b/test/crate_test.rb index 379a389..a471927 100644 --- a/test/crate_test.rb +++ b/test/crate_test.rb @@ -377,4 +377,24 @@ class CrateTest < Test::Unit::TestCase assert_nil crate.get('#joe') assert crate.get('#joehouse') end + + test 'defaults to RO-Crate spec 1.2' do + crate = ROCrate::Crate.new + assert_equal '1.2', crate.metadata.version + assert_equal 'https://w3id.org/ro/crate/1.2/context', crate.metadata.context + assert_equal 'https://w3id.org/ro/crate/1.2', crate.metadata.spec_url + assert_equal({ '@id' => 'https://w3id.org/ro/crate/1.2' }, crate.metadata.properties['conformsTo']) + end + + test 'can write older spec version' do + crate = ROCrate::Crate.new(ROCrate::Crate::IDENTIFIER, {}, version: '1.1') + assert_equal '1.1', crate.metadata.version + assert_equal 'https://w3id.org/ro/crate/1.1/context', crate.metadata.context + assert_equal({ '@id' => 'https://w3id.org/ro/crate/1.1' }, crate.metadata.properties['conformsTo']) + end + + test 'rejects unsupported spec version' do + assert_raise(ArgumentError) { ROCrate::Crate.new(ROCrate::Crate::IDENTIFIER, {}, version: '1.5') } + assert_raise(ArgumentError) { ROCrate::Crate.new(ROCrate::Crate::IDENTIFIER, {}, version: 'v1.2') } + end end From 5252ed3004121a7fce2ca831854adc6bf76f25f8 Mon Sep 17 00:00:00 2001 From: "chia-lin.lin" Date: Thu, 7 May 2026 16:22:20 +0200 Subject: [PATCH 2/2] Address PR #38 review: warn instead of raise, preserve parsed version - Crate#initialize and Metadata#initialize now warn (not raise) on unrecognized version, allowing forward-compat with future spec versions that need no structural changes. - Add Metadata#version= setter. - Reader extracts version from parsed conformsTo and applies it via the setter, so reading a 1.1 crate yields crate.metadata.version == "1.1" instead of being silently re-stamped to the default. - Adjust crate_test to assert warn behavior; add reader_test for 1.1. --- lib/ro_crate/model/crate.rb | 4 +--- lib/ro_crate/model/metadata.rb | 21 ++++++++++++++++++--- lib/ro_crate/reader.rb | 23 ++++++++++++++++++++++- test/crate_test.rb | 13 ++++++++++--- test/reader_test.rb | 8 ++++++++ 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/lib/ro_crate/model/crate.rb b/lib/ro_crate/model/crate.rb index 30c336a..722d74c 100644 --- a/lib/ro_crate/model/crate.rb +++ b/lib/ro_crate/model/crate.rb @@ -27,9 +27,7 @@ def self.format_local_id(id) # @param version [String] RO-Crate spec version to declare (default: ROCrate::Metadata::DEFAULT_VERSION). # Must be one of ROCrate::Metadata::SUPPORTED_VERSIONS. def initialize(id = IDENTIFIER, properties = {}, version: ROCrate::Metadata::DEFAULT_VERSION) - unless ROCrate::Metadata::SUPPORTED_VERSIONS.include?(version) - raise ArgumentError, "Unsupported RO-Crate version: #{version.inspect}. Supported: #{ROCrate::Metadata::SUPPORTED_VERSIONS.join(', ')}" - end + ROCrate::Metadata.warn_unrecognized_version(version) @data_entities = Set.new @contextual_entities = Set.new @metadata_version = version diff --git a/lib/ro_crate/model/metadata.rb b/lib/ro_crate/model/metadata.rb index d15c0eb..4ef14d6 100644 --- a/lib/ro_crate/model/metadata.rb +++ b/lib/ro_crate/model/metadata.rb @@ -14,14 +14,29 @@ class Metadata < File attr_reader :version + ## + # Emit a warning if the given version is not in SUPPORTED_VERSIONS. + # Does not raise — unrecognized versions are still accepted so the library + # stays forward-compatible with future spec versions that need no changes. + def self.warn_unrecognized_version(v) + return if SUPPORTED_VERSIONS.include?(v) + warn "Unrecognized RO-Crate version: #{v.inspect}. Known versions: #{SUPPORTED_VERSIONS.join(', ')}" + end + def initialize(crate, properties = {}, version: DEFAULT_VERSION) - unless SUPPORTED_VERSIONS.include?(version) - raise ArgumentError, "Unsupported RO-Crate version: #{version.inspect}. Supported: #{SUPPORTED_VERSIONS.join(', ')}" - end + self.class.warn_unrecognized_version(version) @version = version super(crate, nil, IDENTIFIER, properties) end + ## + # Update the spec version this metadata declares. + # Used by the Reader to preserve the version of a parsed crate. + def version=(v) + self.class.warn_unrecognized_version(v) + @version = v + end + def context_url "#{RO_CRATE_BASE}#{@version}/context" end diff --git a/lib/ro_crate/reader.rb b/lib/ro_crate/reader.rb index 0cad4ac..368a011 100644 --- a/lib/ro_crate/reader.rb +++ b/lib/ro_crate/reader.rb @@ -185,7 +185,10 @@ def self.build_crate(entity_hash, source, crate_class: ROCrate::Crate, context:) def self.initialize_crate(entity_hash, source, crate_class: ROCrate::Crate, context:) crate_class.new.tap do |crate| crate.properties = entity_hash.delete(ROCrate::Crate::IDENTIFIER) - crate.metadata.properties = entity_hash.delete(ROCrate::Metadata::IDENTIFIER) + metadata_props = entity_hash.delete(ROCrate::Metadata::IDENTIFIER) + crate.metadata.properties = metadata_props + parsed_version = extract_version(metadata_props) + crate.metadata.version = parsed_version if parsed_version crate.metadata.context = context preview_properties = entity_hash.delete(ROCrate::Preview::IDENTIFIER) preview_path = ::File.join(source, ROCrate::Preview::IDENTIFIER) @@ -285,6 +288,24 @@ def self.extract_metadata_entity(entities) entities.delete(ROCrate::Metadata::IDENTIFIER_1_0)) end + ## + # Extract the spec version from the metadata entity's `conformsTo`. + # Looks for an `@id` matching `https://w3id.org/ro/crate/` and returns ``. + # @param metadata_props [Hash, nil] The metadata entity's properties. + # @return [String, nil] The parsed version string, or nil if not found. + def self.extract_version(metadata_props) + return nil unless metadata_props + conforms = metadata_props['conformsTo'] + conforms = [conforms] unless conforms.is_a?(Array) + conforms.compact.each do |c| + id = c.is_a?(Hash) ? c['@id'] : c + next unless id&.start_with?(ROCrate::Metadata::RO_CRATE_BASE) + version = id.sub(ROCrate::Metadata::RO_CRATE_BASE, '').split('/').first + return version if version && !version.empty? + end + nil + end + ## # Extract the ro-crate-preview entity from the entity hash. # @return [Hash{String => Hash}] A Hash containing the preview entity's properties mapped by its @id, or nil if nothing is found. diff --git a/test/crate_test.rb b/test/crate_test.rb index a471927..2e30922 100644 --- a/test/crate_test.rb +++ b/test/crate_test.rb @@ -393,8 +393,15 @@ class CrateTest < Test::Unit::TestCase assert_equal({ '@id' => 'https://w3id.org/ro/crate/1.1' }, crate.metadata.properties['conformsTo']) end - test 'rejects unsupported spec version' do - assert_raise(ArgumentError) { ROCrate::Crate.new(ROCrate::Crate::IDENTIFIER, {}, version: '1.5') } - assert_raise(ArgumentError) { ROCrate::Crate.new(ROCrate::Crate::IDENTIFIER, {}, version: 'v1.2') } + test 'warns but accepts unrecognized spec version' do + original_stderr = $stderr + $stderr = StringIO.new + crate = ROCrate::Crate.new(ROCrate::Crate::IDENTIFIER, {}, version: '1.5') + err = $stderr.string + $stderr = original_stderr + + assert_match(/Unrecognized RO-Crate version/, err) + assert_equal '1.5', crate.metadata.version + assert_equal 'https://w3id.org/ro/crate/1.5', crate.metadata.spec_url end end diff --git a/test/reader_test.rb b/test/reader_test.rb index 5eec130..268facb 100644 --- a/test/reader_test.rb +++ b/test/reader_test.rb @@ -400,4 +400,12 @@ def check_exception(exception_class) e end + + test 'reads spec 1.1 RO-Crate and preserves version' do + crate = ROCrate::Reader.read(fixture_file('crate-spec1.1').path) + + assert_equal '1.1', crate.metadata.version + assert_equal 'https://w3id.org/ro/crate/1.1', crate.metadata.spec_url + assert_equal 'https://w3id.org/ro/crate/1.1/context', crate.metadata.context_url + end end