diff --git a/lib/hyrax/specs/shared_specs/valkyrie_storage_versions.rb b/lib/hyrax/specs/shared_specs/valkyrie_storage_versions.rb new file mode 100644 index 0000000000..cc0533098f --- /dev/null +++ b/lib/hyrax/specs/shared_specs/valkyrie_storage_versions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a Valkyrie::StorageAdapter with versioning support' do + describe '#supports?' do + it 'supports versions' do + expect(storage_adapter.supports?(:versions)).to eq true + end + end +end diff --git a/lib/wings/valkyrie/storage.rb b/lib/wings/valkyrie/storage.rb index 7c3da4d5ec..0b958e973b 100644 --- a/lib/wings/valkyrie/storage.rb +++ b/lib/wings/valkyrie/storage.rb @@ -19,6 +19,7 @@ class Storage < ::Valkyrie::Storage::Fedora DEFAULT_CTYPE = 'application/octet-stream' LINK_HEADER = "; rel=\"type\"" FILES_PATH = 'files' + VERSIONS_SLUG = '/fcr:versions' attr_reader :sha1 @@ -27,6 +28,14 @@ def initialize(connection: Ldp::Client.new(ActiveFedora.fedora.host), base_path: super end + ## + # @param key [Symbol] the key for plugin behavior to check support for + # + # @return [Boolean] whether + def supports?(key) + key == :versions + end + def upload(file:, original_filename:, resource:, content_type: DEFAULT_CTYPE, # rubocop:disable Metrics/ParameterLists resource_uri_transformer: default_resource_uri_transformer, use: Hydra::PCDM::Vocab::PCDMTerms.File, id_hint: 'original', **extra_arguments) @@ -41,7 +50,49 @@ def upload(file:, original_filename:, resource:, content_type: DEFAULT_CTYPE, # id_hint: id_hint, **extra_arguments) end - find_by(id: ::Valkyrie::ID.new(id.to_s.sub(/^.+\/\//, PROTOCOL))) + find_by(id: cast_to_valkyrie_id(id)) + end + + ## + # @return [Enumerable ordered list of versions + def find_versions(id:) + response = connection.http.get(fedora_identifier(id: id) + VERSIONS_SLUG) + return [] if response.status == 404 + + reader = RDF::Reader.for(content_type: response.headers['content-type']) + version_graph = RDF::Graph.new << reader.new(response.body) + + version_graph.query(predicate: RDF::Vocab::Fcrepo4.hasVersion).objects.map do |uri| + timestamp = + version_graph.query([uri, RDF::Vocab::Fcrepo4.created, :created]) + .first_object + .object + Version.new(cast_to_valkyrie_id(uri.to_s), timestamp, self) + end.sort + end + + ## + # abstractly, {Version} objects should have an {#id} and be orderable + # over {#<=>} (allowing e.g. `#sort` to define a consistent order--- + # oldest to newest---for a collection of versions). the {#id} should be a + # globally unique identifier for the version. + # + # this implementation uses an orderable {#version_token}. in practice + # the token is the fcrepo created date for the version, as extracted from + # the versions graph. + Version = Struct.new(:id, :version_token, :adapter) do + include Comparable + + ## + # @return [#read] + def io + adapter.find_by(id: id) + end + + def <=>(other) + raise ArgumentError unless other.respond_to?(:version_token) + version_token <=> other.version_token + end end private @@ -87,6 +138,10 @@ def upload_with_works(resource:, file:, use:) Hyrax.config.translate_id_to_uri.call(created_file.id) end + + def cast_to_valkyrie_id(id) + ::Valkyrie::ID.new(id.to_s.sub(/^.+\/\//, PROTOCOL)) + end end end diff --git a/spec/wings/valkyrie/storage_spec.rb b/spec/wings/valkyrie/storage_spec.rb index c7d0947064..7859278d63 100644 --- a/spec/wings/valkyrie/storage_spec.rb +++ b/spec/wings/valkyrie/storage_spec.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true require 'spec_helper' +require 'hyrax/specs/shared_specs/valkyrie_storage_versions' require 'valkyrie/specs/shared_specs' RSpec.describe Wings::Valkyrie::Storage, :clean_repo do @@ -7,6 +8,40 @@ let(:file) { fixture_file_upload('/world.png', 'image/png') } it_behaves_like "a Valkyrie::StorageAdapter" + it_behaves_like "a Valkyrie::StorageAdapter with versioning support" + + context 'when accessing an existing AF file' do + let(:content) { StringIO.new("test content") } + let(:file_set) { FactoryBot.create(:file_set) } + + before do + Hydra::Works::AddFileToFileSet + .call(file_set, content, :original_file, versioning: true) + end + + describe '#find_versions' do + let(:new_content) { StringIO.new("new content") } + + it 'lists versioned ids' do + id = Hyrax::Base.id_to_uri(file_set.original_file.id) + + expect { Hydra::Works::AddFileToFileSet.call(file_set, new_content, :original_file, versioning: true) } + .to change { storage_adapter.find_versions(id: id).size } + .from(1) + .to(2) + end + + it 'can retrieve versioned content' do + id = Hyrax::Base.id_to_uri(file_set.original_file.id) + + Hydra::Works::AddFileToFileSet + .call(file_set, new_content, :original_file, versioning: true) + + expect(storage_adapter.find_versions(id: id).last.io.read) + .to eq "new content" + end + end + end context 'when uploading with a file_set' do let(:file_set) { FactoryBot.valkyrie_create(:hyrax_file_set) } @@ -69,5 +104,47 @@ .to(2) end end + + describe '#find_versions' do + it 'gives an empty set when the id does not resolve' do + expect(storage_adapter.find_versions(id: 'not_a_real_id')) + .to be_empty + end + + context 'with existing versions' do + let(:another_file) { fixture_file_upload('/hyrax_generic_stub.txt') } + let(:new_use) { RDF::URI('http://example.com/ns/supplemental_file') } + + let(:uploaded) do + storage_adapter.upload(resource: file_set, + file: file, + original_filename: file.original_filename) + end + + it 'finds existing versions' do + uploaded + + expect(storage_adapter.find_versions(id: uploaded.id)) + .to contain_exactly(have_attributes(id: uploaded.id.to_s + '/fcr:versions/version1')) + end + + it 'adds new versions for existing files' do + uploaded + + expect { storage_adapter.upload(resource: file_set, file: another_file, original_filename: 'filenew.txt') } + .to change { storage_adapter.find_versions(id: uploaded.id) } + .to contain_exactly(have_attributes(id: uploaded.id.to_s + '/fcr:versions/version1'), + have_attributes(id: uploaded.id.to_s + '/fcr:versions/version2')) + end + + it 'does not add a version when uploading with a different use argument' do + uploaded + + expect { storage_adapter.upload(resource: file_set, file: another_file, original_filename: 'filenew.txt', use: new_use) } + .not_to change { storage_adapter.find_versions(id: uploaded.id) } + .from contain_exactly(have_attributes(id: uploaded.id.to_s + '/fcr:versions/version1')) + end + end + end end end