valkyrie-shrine 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a7a482b7fc9181f9af0f7a9b9f4fa4d7cc131c9b30489414799b7a0cc16d2c26
4
- data.tar.gz: f20feb6e99e26a0c7e4af2bd39b19f2ad5a90d369b142d699abedbaa23829d64
3
+ metadata.gz: c3422215c665f0b4a25b59b9d62537318f364e1c5522bd8b352de0f92e7c1a03
4
+ data.tar.gz: e5e537adc20cb7c9f5ca8f1382148263c9631935136de7ac5f9a5998051c7ee2
5
5
  SHA512:
6
- metadata.gz: 547028b04de7538b83d9bb1685e3424722691be427c40a3c33545b646c5df99cb1ff01ea7b899c175bb330c0163c4359c5d437e6bda81063ae17e4efcbf03273
7
- data.tar.gz: 824399812e4df139b35c13c0d45c0adcf35d5c41528ebd235b8d95ecd8d07d7a0b4ae0bb409bec0d7cefdc288dfae8522c1e6c79a39f36842b2ea6e7e6074623
6
+ metadata.gz: ba9e4ac128bb0cf59ea04cd825dac307decda1b1ad97b21f4fb46e8373d0714d91be79f31f511cb5bb680db126b2ea28117f62d243510d6bcec1c85e8df6fb77
7
+ data.tar.gz: 5a6a6d9e534cbf4ef8ec561a2a3c569e93862f9b64d2c9f7236ab54428e0a73b2609862c27b05a9fbf340ef714f28facf11d76a74024753873b7247b9f5ede22
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [Shrine](http://shrinerb.com/) storage adapter for [Valkyrie](https://github.com/samvera-labs/valkyrie).
4
4
 
5
5
  [![CircleCI](https://circleci.com/gh/samvera-labs/valkyrie-shrine.svg?style=svg)](https://circleci.com/gh/samvera-labs/valkyrie-shrine)
6
- [![Coverage Status](https://coveralls.io/repos/github/samvera-labs/valkyrie-shrine/badge.svg?branch=master)](https://coveralls.io/github/samvera-labs/valkyrie-shrine?branch=master)
6
+ ![Coverage Status](https://img.shields.io/badge/Coverage-100-brightgreen.svg)
7
7
 
8
8
  ## Installation
9
9
 
@@ -21,8 +21,9 @@ Follow the Valkyrie [README](https://github.com/samvera-labs/valkyrie) to get a
21
21
  # config/initializers/valkyrie.rb
22
22
  require 'shrine/storage/s3'
23
23
  require 'shrine/storage/file_system'
24
- require 'valkyrie/storage/shrine/checksum/s3'
25
- require 'valkyrie/storage/shrine/checksum/file_system'
24
+ require 'valkyrie/shrine/checksum/s3'
25
+ require 'valkyrie/shrine/checksum/file_system'
26
+ require 'valkyrie/shrine/storage/s3'
26
27
 
27
28
  Shrine.storages = {
28
29
  file: Shrine::Storage::FileSystem.new("public", prefix: "uploads"),
@@ -36,6 +37,18 @@ Follow the Valkyrie [README](https://github.com/samvera-labs/valkyrie) to get a
36
37
  Valkyrie::StorageAdapter.register(
37
38
  Valkyrie::Storage::Shrine.new(Shrine.storages[:file]), :disk
38
39
  )
40
+
41
+ s3_options = {
42
+ access_key_id: s3_access_key,
43
+ bucket: s3_bucket,
44
+ endpoint: s3_endpoint,
45
+ force_path_style: force_path_style,
46
+ region: s3_region,
47
+ secret_access_key: s3_secret_key
48
+ }
49
+ Valkyrie::StorageAdapter.register(
50
+ Valkyrie::Storage::VersionedShrine.new(Valkyrie::Shrine::Storage::S3.new(**s3_options)), :versioned_s3
51
+ )
39
52
  ```
40
53
 
41
54
  Then proceed to configure your application following the [Valkyrie documentation](https://github.com/samvera-labs/valkyrie#sample-configuration-configvalkyrieyml)
data/circle.yml CHANGED
@@ -7,6 +7,7 @@ jobs:
7
7
  BUNDLER_VERSION: 2.0.1
8
8
  steps:
9
9
  - checkout
10
+ - run: apt update -y && apt-get install -y lsof
10
11
  - run:
11
12
  name: Install Bundler 2.0.1
12
13
  command: gem install --no-doc bundler:2.0.1
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shrine/storage/s3"
4
+
5
+ module Valkyrie
6
+ module Shrine
7
+ module Storage
8
+ class S3 < ::Shrine::Storage::S3
9
+ # List objects that are starting with a given prefix.
10
+ # This is helpful for versioned files that have a common file identifier.
11
+ # Need to make sure to combine with storage prefix.
12
+ # list_objects(id_prefix: "some/object/id")
13
+ # @param id_prefix [String] - object's id that starts with
14
+ # @return [Array(Aws::S3::Object)]
15
+ def list_objects(id_prefix:)
16
+ aws_prefix = [*prefix, id_prefix].join("/")
17
+ bucket.objects(prefix: aws_prefix)
18
+ end
19
+
20
+ # List objects id's that are starting with a given prefix.
21
+ # This is helpful for versioned files that have a common file identifier.
22
+ # Need to make sure to combine with storage prefix.
23
+ # list_object_ids(id_prefix: "some/object/id")
24
+ # @param id_prefix [String] - object's id that starts with
25
+ # @return [Array(String)]
26
+ def list_object_ids(id_prefix:)
27
+ objects = list_objects(id_prefix: id_prefix)
28
+ object_ids_for(objects)
29
+ end
30
+
31
+ # Move a file to a another location.
32
+ # @param id [String] - the id of the source file
33
+ # @param destination_id [String] - the id of the destination file
34
+ # @param destination_bucket [String] - the bucket name of the destination
35
+ # @return [String]
36
+ def move_to(id:, destination_id:, destination_bucket: bucket.name)
37
+ source_object = Aws::S3::Object.new(bucket.name, object_key(id), client: client)
38
+ destination_key = "#{destination_bucket}/#{object_key(destination_id)}"
39
+ source_object.move_to(destination_key)
40
+ destination_key
41
+ end
42
+
43
+ # Deletes all objects in fewest requests possible.
44
+ # @see +super#delete_objects(objects)+.
45
+ # @return [Array(string)] List of object id's that are deleted
46
+ def delete_objects(objects)
47
+ super
48
+ object_ids_for(objects)
49
+ end
50
+
51
+ private
52
+
53
+ # @ return list of object id's
54
+ # @param objects [Array(Aws::S3::Object)]
55
+ def object_ids_for(objects)
56
+ keys = objects.map(&:key)
57
+ return keys if prefix.blank?
58
+
59
+ keys.map { |k| k.delete_prefix("#{prefix}/") }
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Valkyrie
4
4
  module Shrine
5
- VERSION = "1.0.0"
5
+ VERSION = "1.1.0"
6
6
  end
7
7
  end
@@ -5,6 +5,8 @@ require 'valkyrie/storage/shrine'
5
5
  require 'valkyrie/shrine/checksum/base'
6
6
  require 'valkyrie/shrine/checksum/file_system'
7
7
  require 'valkyrie/shrine/checksum/s3'
8
+ require 'valkyrie/shrine/storage/s3'
9
+ require 'valkyrie/storage/versioned_shrine'
8
10
 
9
11
  module Valkyrie
10
12
  module Shrine
@@ -69,16 +69,9 @@ module Valkyrie
69
69
  # @raise Valkyrie::Shrine::IntegrityError if #verify_checksum is defined
70
70
  # on the shrine object and the file and result digests do not match
71
71
  def upload(file:, original_filename:, resource:, **upload_options)
72
- # S3 adapter validates options, so we have to remove this one used in
73
- # the shared specs.
74
- upload_options.delete(:fake_upload_argument)
75
72
  identifier = path_generator.generate(resource: resource, file: file, original_filename: original_filename).to_s
76
- shrine.upload(file, identifier, **upload_options)
77
- find_by(id: "#{protocol_with_prefix}#{identifier}").tap do |result|
78
- if verifier
79
- raise Valkyrie::Shrine::IntegrityError unless verifier.verify_checksum(file, result)
80
- end
81
- end
73
+
74
+ upload_file(file: file, identifier: identifier, **upload_options)
82
75
  end
83
76
 
84
77
  # Return the file associated with the given identifier
@@ -88,8 +81,6 @@ module Valkyrie
88
81
  def find_by(id:)
89
82
  raise Valkyrie::StorageAdapter::FileNotFound unless shrine.exists?(shrine_id_for(id))
90
83
  Valkyrie::StorageAdapter::StreamFile.new(id: Valkyrie::ID.new(id.to_s), io: DelayedDownload.new(shrine, shrine_id_for(id)))
91
- rescue Aws::S3::Errors::NoSuchKey
92
- raise Valkyrie::StorageAdapter::FileNotFound
93
84
  end
94
85
 
95
86
  # @param id [Valkyrie::ID]
@@ -104,8 +95,39 @@ module Valkyrie
104
95
  shrine.delete(shrine_id_for(id))
105
96
  end
106
97
 
98
+ # @param _feature [Symbol] Feature to test for.
99
+ # @return [Boolean] true if the adapter supports the given feature
100
+ def supports?(_feature)
101
+ false
102
+ end
103
+
104
+ # @return [String] identifier prefix
105
+ def protocol
106
+ protocol_with_prefix
107
+ end
108
+
107
109
  private
108
110
 
111
+ # Upload file with a given shrine object idenfifier (without prefix #protocol_with_prefix)
112
+ # @param file [IO]
113
+ # @param original_filename [String]
114
+ # @param resource [Valkyrie::Resource]
115
+ # @return [Valkyrie::StorageAdapter::StreamFile]
116
+ # @raise Valkyrie::Shrine::IntegrityError if #verify_checksum is defined
117
+ # on the shrine object and the file and result digests do not match
118
+ def upload_file(file:, identifier:, **upload_options)
119
+ # S3 adapter validates options, so we have to remove this one used in
120
+ # the shared specs.
121
+ upload_options.delete(:fake_upload_argument)
122
+
123
+ shrine.upload(file, identifier, **upload_options)
124
+ find_by(id: "#{protocol_with_prefix}#{identifier}").tap do |result|
125
+ if verifier
126
+ raise Valkyrie::Shrine::IntegrityError unless verifier.verify_checksum(file, result)
127
+ end
128
+ end
129
+ end
130
+
109
131
  def try_to_find_verifier
110
132
  class_const = shrine.class.name.split(/::/).last.to_sym
111
133
  @verifier = Valkyrie::Shrine::Checksum.const_get(class_const).new if Valkyrie::Shrine::Checksum.const_defined?(class_const)
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Valkyrie
4
+ module Storage
5
+ # The VersionedShrine adapter implements versioned storage on S3 that manages versions
6
+ # through Shrine object id with a timestamp like shrine://[resource_id]/[UUID]/v-[timestamp].
7
+ #
8
+ # Example to use VersionedShrine storage adapter:
9
+ # shrine_s3_options = {
10
+ # access_key_id: s3_access_key,
11
+ # bucket: s3_bucket,
12
+ # endpoint: s3_endpoint,
13
+ # force_path_style: force_path_style,
14
+ # region: s3_region,
15
+ # secret_access_key: s3_secret_key
16
+ # }
17
+ # Valkyrie::StorageAdapter.register(
18
+ # Valkyrie::Storage::VersionedShrine.new(Valkyrie::Shrine::Storage::S3.new(**shrine_s3_options)),
19
+ # :s3_repository
20
+ # )
21
+ class VersionedShrine < Shrine
22
+ # @param feature [Symbol] Feature to test for.
23
+ # @return [Boolean] true if the adapter supports the given feature
24
+ def supports?(feature)
25
+ feature == :versions || feature == :version_deletion
26
+ end
27
+
28
+ # Retireve all files versions with no deletion marker that are associated from S3.
29
+ # @param id [Valkyrie::ID]
30
+ # @return [Array(Valkyrie::StorageAdapter::StreamFile)]
31
+ def find_versions(id:)
32
+ version_files(id: id).map { |f| find_by(id: f) }
33
+ end
34
+
35
+ # Retireve all file versions associated with the given identifier from S3
36
+ # @param id [Valkyrie::ID]
37
+ # @return [Array(Valkyrie::ID)] - list of file identifiers
38
+ def version_files(id:)
39
+ shrine.list_object_ids(id_prefix: shrine_id_for(id))
40
+ .sort
41
+ .reverse
42
+ .map { |v| Valkyrie::ID.new(protocol_with_prefix + v) }
43
+ end
44
+
45
+ # Upload a file via the VersionedShrine storage adapter with a version id assigned.
46
+ # @param file [IO]
47
+ # @param original_filename [String]
48
+ # @param resource [Valkyrie::Resource]
49
+ # @return [Valkyrie::StorageAdapter::StreamFile]
50
+ # @raise Valkyrie::Shrine::IntegrityError if #verify_checksum is defined
51
+ # on the shrine object and the file and result digests do not match
52
+ def upload(file:, original_filename:, resource:, **upload_options)
53
+ identifier = path_generator.generate(resource: resource, file: file, original_filename: original_filename)
54
+ perform_upload(id: "#{protocol_with_prefix}#{identifier}", file: file, **upload_options)
55
+ end
56
+
57
+ # Upload a new version file
58
+ # @param id [Valkyrie::ID] ID of the Valkyrie::StorageAdapter::File to version.
59
+ # @param file [IO]
60
+ def upload_version(id:, file:, **upload_options)
61
+ # For backward compatablity with files ingested in the past and we don't have to migrate it to versioned fies.
62
+ # If there is a file associated with the given identifier that is not a versioned file,
63
+ # simply convert it to a versioned file basing on last_modified time to keep all versioned files consistent.
64
+ migrate_to_versioned(id: id) if shrine.exists?(shrine_id_for(id))
65
+
66
+ perform_upload(id: id, file: file, **upload_options)
67
+ end
68
+
69
+ # Upload a file with a version id assigned.
70
+ # @param id [Valkyrie::ID] ID of the Valkyrie::StorageAdapter::File to version.
71
+ # @param file [IO]
72
+ # @return [Valkyrie::StorageAdapter::StreamFile]
73
+ # @raise Valkyrie::Shrine::IntegrityError if #verify_checksum is defined
74
+ def perform_upload(id:, file:, **upload_options)
75
+ shrine_id = shrine_id_for(VersionId.new(id).new_version)
76
+ upload_file(file: file, identifier: shrine_id, **upload_options)
77
+ end
78
+
79
+ # Delete the versioned file or delete all versions in S3 associated with the given identifier.
80
+ # @param id [Valkyrie::ID]
81
+ # @return [Array(Valkyrie::ID)] - file id's that are deleted.
82
+ # @raise Valkyrie::StorageAdapter::FileNotFound if nothing is found
83
+ def delete(id:)
84
+ objects = objects_to_delete(id)
85
+ raise Valkyrie::StorageAdapter::FileNotFound if objects.blank?
86
+
87
+ shrine.delete_objects(objects)
88
+ .map { |obj_id| Valkyrie::ID.new(protocol_with_prefix + obj_id) }
89
+ end
90
+
91
+ # Find the file associated with the given version identifier
92
+ #
93
+ # Note: we need override it to use the latest version
94
+ # so that file characterization and derivative creation can use the latest file uploaded
95
+ # @param id [Valkyrie::ID]
96
+ # @return [Valkyrie::StorageAdapter::StreamFile]
97
+ # @raise Valkyrie::StorageAdapter::FileNotFound if nothing is found
98
+ def find_by(id:)
99
+ version_id = resolve_current(id)
100
+
101
+ raise Valkyrie::StorageAdapter::FileNotFound unless version_id && shrine.exists?(shrine_id_for(version_id.id))
102
+ Valkyrie::StorageAdapter::StreamFile.new(id: Valkyrie::ID.new(version_id.base_identifier),
103
+ io: DelayedDownload.new(shrine, shrine_id_for(version_id.id)),
104
+ version_id: version_id.id)
105
+ end
106
+
107
+ # @return VersionId A VersionId value that's resolved to a current version,
108
+ # so we can access the latest version with a base identifier that is not a version.
109
+ def resolve_current(id)
110
+ version_id = VersionId.new(id)
111
+ return version_id if version_id.versioned?
112
+ version_files = version_files(id: Valkyrie::ID.new(version_id.base_identifier))
113
+ return nil if version_files.blank?
114
+ VersionId.new(Valkyrie::ID.new(version_files.first))
115
+ end
116
+
117
+ # @param id [Valkyrie::ID]
118
+ # @return [Array(Aws::S3::Object)] list of objects
119
+ def objects_to_delete(id)
120
+ shrine_id = shrine_id_for(id)
121
+ version_id = VersionId.new(id)
122
+ return shrine.list_objects(id_prefix: shrine_id).to_a unless version_id.versioned?
123
+
124
+ shrine.exists?(shrine_id) ? [shrine.object(shrine_id)] : []
125
+ end
126
+
127
+ # Convert a non-versioned file to a version file basing on its last_modified time.
128
+ # @param id [Valkyrie::ID]
129
+ # @return [VerrsionId]
130
+ def migrate_to_versioned(id:)
131
+ shrine_id = shrine_id_for(id)
132
+ last_modified = shrine.object(shrine_id).last_modified
133
+ version_id = VersionId.new(id).new_version(timestamp: last_modified)
134
+ shrine.move_to(id: shrine_id, destination_id: shrine_id_for(version_id))
135
+ version_id
136
+ end
137
+
138
+ # A class that holds a version id and methods for knowing things about it.
139
+ # Examples of version ids in this adapter:
140
+ # * shrine://[resource_id]/[uuid]/v-20250429142441274
141
+ #
142
+ # @note With '/' as path delimiter for versions like '/v-', there is an issue with MinIO
143
+ # that fails to list the version files if a file with a base identifier
144
+ # that is not a version exisits along with other versioned files associated with the base identifier.
145
+ class VersionId
146
+ VERSION_DELIMITER = "/v-"
147
+
148
+ attr_reader :id
149
+ def initialize(id)
150
+ @id = id
151
+ end
152
+
153
+ # Create new version identifier basing on the given identifier, which could be the original file identifier like
154
+ # shrine://[resource_id]/[uuid], or a version identifier like shrine://[resource_id]/[uuid]/v-20250429142441274.
155
+ # @param timestamp [Time]
156
+ # @return [String]
157
+ def new_version(timestamp: nil)
158
+ version_timestamp = (timestamp&.utc || Time.now.utc).strftime("%Y%m%d%H%M%S%L")
159
+ versioned? ? string_id.gsub(version, version_timestamp) : string_id + VERSION_DELIMITER + version_timestamp
160
+ end
161
+
162
+ def versioned?
163
+ string_id.include?(VERSION_DELIMITER)
164
+ end
165
+
166
+ def version
167
+ string_id.split(VERSION_DELIMITER).last
168
+ end
169
+
170
+ # @return [String] The base identifier that is not a version
171
+ def base_identifier
172
+ string_id.split(VERSION_DELIMITER).first
173
+ end
174
+
175
+ def string_id
176
+ id.to_s
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -29,11 +29,11 @@ Gem::Specification.new do |spec|
29
29
 
30
30
  spec.add_development_dependency 'bixby', '~> 2.0.0.pre.beta1'
31
31
  spec.add_development_dependency 'bundler', '~> 2.0'
32
- spec.add_development_dependency 'coveralls', '~> 0.8.3'
33
32
  spec.add_development_dependency 'pry'
34
33
  spec.add_development_dependency 'pry-byebug'
35
34
  spec.add_development_dependency 'rake', '>= 12.3.3'
36
35
  spec.add_development_dependency 'rspec', '~> 3.0'
37
36
  spec.add_development_dependency 'simplecov'
38
37
  spec.add_development_dependency 'actionpack'
38
+ spec.add_development_dependency 'webmock'
39
39
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: valkyrie-shrine
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brendan Quinn
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-07-11 00:00:00.000000000 Z
11
+ date: 2025-11-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk-s3
@@ -86,20 +86,6 @@ dependencies:
86
86
  - - "~>"
87
87
  - !ruby/object:Gem::Version
88
88
  version: '2.0'
89
- - !ruby/object:Gem::Dependency
90
- name: coveralls
91
- requirement: !ruby/object:Gem::Requirement
92
- requirements:
93
- - - "~>"
94
- - !ruby/object:Gem::Version
95
- version: 0.8.3
96
- type: :development
97
- prerelease: false
98
- version_requirements: !ruby/object:Gem::Requirement
99
- requirements:
100
- - - "~>"
101
- - !ruby/object:Gem::Version
102
- version: 0.8.3
103
89
  - !ruby/object:Gem::Dependency
104
90
  name: pry
105
91
  requirement: !ruby/object:Gem::Requirement
@@ -184,6 +170,20 @@ dependencies:
184
170
  - - ">="
185
171
  - !ruby/object:Gem::Version
186
172
  version: '0'
173
+ - !ruby/object:Gem::Dependency
174
+ name: webmock
175
+ requirement: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - ">="
178
+ - !ruby/object:Gem::Version
179
+ version: '0'
180
+ type: :development
181
+ prerelease: false
182
+ version_requirements: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - ">="
185
+ - !ruby/object:Gem::Version
186
+ version: '0'
187
187
  description:
188
188
  email:
189
189
  - brendan-quinn@northwestern.edu
@@ -206,8 +206,10 @@ files:
206
206
  - lib/valkyrie/shrine/checksum/base.rb
207
207
  - lib/valkyrie/shrine/checksum/file_system.rb
208
208
  - lib/valkyrie/shrine/checksum/s3.rb
209
+ - lib/valkyrie/shrine/storage/s3.rb
209
210
  - lib/valkyrie/shrine/version.rb
210
211
  - lib/valkyrie/storage/shrine.rb
212
+ - lib/valkyrie/storage/versioned_shrine.rb
211
213
  - valkyrie-shrine.gemspec
212
214
  homepage: https://github.com/samvera-labs/valkyrie-shrine
213
215
  licenses:
@@ -228,7 +230,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
228
230
  - !ruby/object:Gem::Version
229
231
  version: '0'
230
232
  requirements: []
231
- rubygems_version: 3.1.4
233
+ rubygems_version: 3.4.1
232
234
  signing_key:
233
235
  specification_version: 4
234
236
  summary: Shrine storage adapter for Valkyrie