nozzle 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +32 -0
- data/Rakefile +11 -0
- data/lib/nozzle/adapter/base.rb +246 -0
- data/lib/nozzle/adapter/image.rb +27 -0
- data/lib/nozzle/adapter/outlet.rb +80 -0
- data/lib/nozzle/adapter.rb +106 -0
- data/lib/nozzle/datamapper.rb +30 -0
- data/lib/nozzle/version.rb +3 -0
- data/lib/nozzle.rb +2 -0
- data/nozzle.gemspec +23 -0
- data/test/fixtures/test-697x960.jpg +0 -0
- data/test/minitest_helper.rb +4 -0
- data/test/nozzle_adapter_base_test.rb +122 -0
- data/test/nozzle_adapter_image_test.rb +48 -0
- data/test/nozzle_adapter_outlet_test.rb +104 -0
- data/test/nozzle_adapter_test.rb +40 -0
- data/test/nozzle_test.rb +7 -0
- metadata +114 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 574bb2f5aafe5e663ec50b3237cacef48010aeab
|
4
|
+
data.tar.gz: 0b66db6e1d494592209504e2fdd92c94c07ffa83
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a8054882ea0598df4fefabf947fdb5d512ff6d9d135a76c1ecd734fb92f70440a0eb223ca719a11f182df019a43cfbd44615ee2e9cf085244a863a5bc15d0c97
|
7
|
+
data.tar.gz: 7489babd43a467689511eea8d175dfab7107e44b0af0236e5d4efd0da480427b159992df58f732016b42726eb62474aa861963e62083ca2970d7021b6eb789ca
|
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 ujifgc
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
[![Build Status](https://travis-ci.org/ujifgc/nozzle.png)](https://travis-ci.org/ujifgc/nozzle)
|
2
|
+
[![Code Climate](https://codeclimate.com/github/ujifgc/nozzle.png)](https://codeclimate.com/github/ujifgc/nozzle)
|
3
|
+
|
4
|
+
# Nozzle
|
5
|
+
|
6
|
+
A gem to store and serve attachments for ruby rack applications
|
7
|
+
|
8
|
+
## Installation
|
9
|
+
|
10
|
+
Add this line to your application's Gemfile:
|
11
|
+
|
12
|
+
gem 'nozzle'
|
13
|
+
|
14
|
+
And then execute:
|
15
|
+
|
16
|
+
$ bundle
|
17
|
+
|
18
|
+
Or install it yourself as:
|
19
|
+
|
20
|
+
$ gem install nozzle
|
21
|
+
|
22
|
+
## Usage
|
23
|
+
|
24
|
+
TODO: Write usage instructions here
|
25
|
+
|
26
|
+
## Contributing
|
27
|
+
|
28
|
+
1. Fork it
|
29
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
30
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
31
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
32
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,246 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'tempfile'
|
3
|
+
require 'nozzle/adapter/outlet'
|
4
|
+
|
5
|
+
module Nozzle
|
6
|
+
module Adapter
|
7
|
+
class Base
|
8
|
+
include Nozzle::Adapter::Outlet
|
9
|
+
|
10
|
+
# Initializes internal structure of new adapter.
|
11
|
+
# outlet_class.new( instance, :avatar, 'image.jpg', :fake => true )
|
12
|
+
def initialize( record, column, filename, options = {} )
|
13
|
+
@record = record
|
14
|
+
@model = record.class
|
15
|
+
@column = column.to_sym
|
16
|
+
@filename = filename
|
17
|
+
settings.merge! options
|
18
|
+
end
|
19
|
+
|
20
|
+
# Sets or gets settings provided by options.
|
21
|
+
# if instance.avatar.settings[:fake]
|
22
|
+
# instance.avatar.settings[:fake] = false
|
23
|
+
# end
|
24
|
+
def settings
|
25
|
+
@settings ||= {}
|
26
|
+
end
|
27
|
+
|
28
|
+
# Constructs an URL which relatively points to the file.
|
29
|
+
# instance.avatar.url # => '/uploads/Model/avatar/image.jpg'
|
30
|
+
# How it's constructed:
|
31
|
+
# "#{public_path}/#{filename}"
|
32
|
+
# "/#{adapter_folder}/#{relative_folder}/#{filename}"
|
33
|
+
# "/uploads/#{@model}/#{@column}/#{filename}"
|
34
|
+
# "/uploads/Model/avatar/image.jpg"
|
35
|
+
# Note: if filename is not yet stored, +default_url+ is called.
|
36
|
+
def url
|
37
|
+
File.join '', public_path, filename
|
38
|
+
rescue TypeError
|
39
|
+
default_url
|
40
|
+
end
|
41
|
+
|
42
|
+
# Constructs a filesustem path which absolutely points to stored file.
|
43
|
+
# instance.avatar.path # => 'public/uploads/Model/avatar/image.jpg'
|
44
|
+
# How it's constructed:
|
45
|
+
# "/#{system_path}/#{filename}"
|
46
|
+
# "/#{adapter_path}/#{relative_folder}/#{filename}"
|
47
|
+
# "#{root}/#{adapter_folder}/#{@model}/#{@column}/#{filename}"
|
48
|
+
# "public/uploads/#{@model}/#{@column}/#{filename}"
|
49
|
+
# "public/uploads/Model/avatar/image.jpg"
|
50
|
+
# Note: if filename is not yet stored, nil is returned.
|
51
|
+
def path
|
52
|
+
File.join system_path, filename
|
53
|
+
rescue TypeError
|
54
|
+
nil
|
55
|
+
end
|
56
|
+
|
57
|
+
# Returns intermediate path to the tempfile if the record is not yet
|
58
|
+
# saved and file is not yet stored at path.
|
59
|
+
def access_path
|
60
|
+
@tempfile_path || path
|
61
|
+
end
|
62
|
+
|
63
|
+
# Returns file content_type stored in avatar_content_type column
|
64
|
+
# of the record.
|
65
|
+
def content_type
|
66
|
+
@record.send( :"#{@column}_content_type" ) rescue ''
|
67
|
+
end
|
68
|
+
|
69
|
+
# Returns file size stored in avatar_size column of the record.
|
70
|
+
def size
|
71
|
+
@record.send( :"#{@column}_size" ) rescue -1
|
72
|
+
end
|
73
|
+
|
74
|
+
# Returns stored filename.
|
75
|
+
# instance.avatar.filename # => 'image.jpg'
|
76
|
+
def filename
|
77
|
+
@filename
|
78
|
+
end
|
79
|
+
|
80
|
+
# Returns nil.
|
81
|
+
# This SHOULD be overridden by subclasses of Nozzle::Adapter::Base.
|
82
|
+
# instance.avatar.default_url # => nil
|
83
|
+
def default_url
|
84
|
+
nil
|
85
|
+
end
|
86
|
+
|
87
|
+
# Returns root path of application's static assets.
|
88
|
+
# instance.avatar.root # => 'public'
|
89
|
+
# This MAY be overridden to return an application root different
|
90
|
+
# from the current folder.
|
91
|
+
def root
|
92
|
+
'public'
|
93
|
+
end
|
94
|
+
|
95
|
+
# Returns folder name of the adapter relative to application public.
|
96
|
+
# instance.avatar.adapter_folder # => 'uploads'
|
97
|
+
# This MAY be overridden to specify where all the files should be stored.
|
98
|
+
def adapter_folder
|
99
|
+
'uploads'
|
100
|
+
end
|
101
|
+
|
102
|
+
# Returns filesystem folder path of the adapter relative to adapter root.
|
103
|
+
# instance.avatar.adapter_path # => 'public/uploads'
|
104
|
+
# It is constructed from #root, 'public' and #adapter_folder.
|
105
|
+
def adapter_path
|
106
|
+
File.join root, adapter_folder
|
107
|
+
end
|
108
|
+
|
109
|
+
# Returns file's folder relative to #adapter_path.
|
110
|
+
# instance.avatar.relative_folder # => 'Model/avatar'
|
111
|
+
# It is constructed from object's class name and column name.
|
112
|
+
# This MAY be overridden to place files somwhere other than 'Model/avatar'.
|
113
|
+
def relative_folder
|
114
|
+
File.join @model.to_s, @column.to_s
|
115
|
+
end
|
116
|
+
|
117
|
+
# Returns filesystem folder path relative to adapter root.
|
118
|
+
# instance.avatar.system_path # => 'public/uploads/Model/avatar'
|
119
|
+
# It is constructed from #adapter_path and #relative_folder
|
120
|
+
def system_path
|
121
|
+
File.join adapter_path, relative_folder
|
122
|
+
end
|
123
|
+
|
124
|
+
# Returns folder path relative to public folder.
|
125
|
+
# instance.avatar.public_path # => 'uploads/Model/avatar'
|
126
|
+
# It is constructed from #adapter_folder and #relative_folder
|
127
|
+
def public_path
|
128
|
+
File.join adapter_folder, relative_folder
|
129
|
+
end
|
130
|
+
|
131
|
+
# Inspects class name and url of the object.
|
132
|
+
# instance.avatar.to_s # => 'Model#url: /uploads/Model/avatar/image.jpg'
|
133
|
+
def to_s
|
134
|
+
"#{self.class}#url: #{url}"
|
135
|
+
end
|
136
|
+
|
137
|
+
# Sets adapter to delete stored file on #adapеr_after_save.
|
138
|
+
# instance.avatar.delete # => nil
|
139
|
+
def delete
|
140
|
+
@record.send(:"#{@column}=", nil)
|
141
|
+
end
|
142
|
+
|
143
|
+
# Returns adapter instance.
|
144
|
+
# It's used in Nozzle::Adapter#avatar after retrieving filename from the object.
|
145
|
+
def load( value )
|
146
|
+
@filename = value
|
147
|
+
self
|
148
|
+
end
|
149
|
+
|
150
|
+
# Fills internal structure of the adapter with new file's path.
|
151
|
+
# It's used in Nozzle::Adapter#avatar= before sending filename to the object.
|
152
|
+
def dump( value )
|
153
|
+
reset
|
154
|
+
@original_path = path
|
155
|
+
return nil unless value
|
156
|
+
|
157
|
+
new_path = expand_argument value
|
158
|
+
raise Errno::ENOENT, "'#{new_path}'" unless File.exists?(new_path)
|
159
|
+
|
160
|
+
@tempfile_path = File.expand_path(new_path)
|
161
|
+
detect_properties
|
162
|
+
@filename
|
163
|
+
end
|
164
|
+
|
165
|
+
# Stores temporary filename by the constructed path. Deletes old file.
|
166
|
+
# Note: the file is moved if it's path contains /tmp/ or /temp/, copied
|
167
|
+
# otherwise.
|
168
|
+
def store!
|
169
|
+
unlink! @original_path
|
170
|
+
return nil unless @tempfile_path
|
171
|
+
|
172
|
+
new_path = path
|
173
|
+
FileUtils.mkdir_p File.dirname(new_path)
|
174
|
+
result = if @tempfile_path =~ /\/te?mp\//
|
175
|
+
FileUtils.move @tempfile_path, new_path
|
176
|
+
else
|
177
|
+
FileUtils.copy @tempfile_path, new_path
|
178
|
+
end
|
179
|
+
File.chmod 0644, new_path
|
180
|
+
reset
|
181
|
+
result
|
182
|
+
end
|
183
|
+
|
184
|
+
# Deletes file by path. Do not use, it will break adapter's integrity.
|
185
|
+
# It's called in #avatar_after_destroy after the object is destroyed.
|
186
|
+
# unlink! # deletes path
|
187
|
+
# unlink! @original_path # deletes @original_path
|
188
|
+
# unlink! nil # deletes nothing
|
189
|
+
def unlink!( target = path )
|
190
|
+
delete_file_and_folder! target if target
|
191
|
+
end
|
192
|
+
|
193
|
+
def as_json
|
194
|
+
{ :url => url }
|
195
|
+
end
|
196
|
+
|
197
|
+
private
|
198
|
+
|
199
|
+
# Tries to detect content_type and size of the file.
|
200
|
+
# Note: this method calls `file` system command to detect file content type.
|
201
|
+
def detect_properties
|
202
|
+
@record.send( :"#{@column}_content_type=", `file -bp --mime-type '#{access_path}'`.to_s.strip )
|
203
|
+
@record.send( :"#{@column}_size=", File.size(access_path) )
|
204
|
+
rescue NoMethodError
|
205
|
+
nil
|
206
|
+
end
|
207
|
+
|
208
|
+
# Resets internal paths.
|
209
|
+
def reset
|
210
|
+
@original_path = nil
|
211
|
+
@tempfile_path = nil
|
212
|
+
end
|
213
|
+
|
214
|
+
# Analyzes the value assigned to adapter and fills @filename. Returns
|
215
|
+
# system path where temporary file is located.
|
216
|
+
# The +value+ MUST be File, String, Hash or nil. See Nozzle::Adapter#avatar=.
|
217
|
+
def expand_argument( value )
|
218
|
+
tempfile_path = case value
|
219
|
+
when String
|
220
|
+
value
|
221
|
+
when File, Tempfile
|
222
|
+
value.path
|
223
|
+
when Hash
|
224
|
+
expand_argument( value[:tempfile] || value['tempfile'] )
|
225
|
+
else
|
226
|
+
raise ArgumentError, "#{@model}##{@column}= argument must be kind of String, File, Tempfile or Hash[:tempfile => 'path']"
|
227
|
+
end
|
228
|
+
@filename = value.kind_of?(Hash) && ( value[:filename] || value['filename'] ) || File.basename(tempfile_path)
|
229
|
+
tempfile_path
|
230
|
+
end
|
231
|
+
|
232
|
+
# Deletes the specified file and all empty folders recursively stopping at
|
233
|
+
# #adapter_folder.
|
234
|
+
def delete_file_and_folder!( file_path )
|
235
|
+
FileUtils.rm_f file_path
|
236
|
+
boundary = adapter_path + '/'
|
237
|
+
loop do
|
238
|
+
file_path = File.dirname file_path
|
239
|
+
break unless file_path.index boundary
|
240
|
+
FileUtils.rmdir file_path
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'nozzle/adapter/base'
|
2
|
+
|
3
|
+
module Nozzle
|
4
|
+
module Adapter
|
5
|
+
class Image < Nozzle::Adapter::Base
|
6
|
+
DEFAULT_SETTINGS = {
|
7
|
+
:thumb_size => '200x150',
|
8
|
+
}.freeze
|
9
|
+
|
10
|
+
def initialize( record, column, filename = nil, options = {} )
|
11
|
+
@settings = DEFAULT_SETTINGS.dup
|
12
|
+
super( record, column, filename, options )
|
13
|
+
end
|
14
|
+
|
15
|
+
def default_url
|
16
|
+
'/images/image_missing.png'
|
17
|
+
end
|
18
|
+
|
19
|
+
outlet :thumb do
|
20
|
+
def prepare( original, result )
|
21
|
+
`convert #{original} -thumbnail #{settings[:thumb_size]} #{result}`
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
module Nozzle
|
2
|
+
module Adapter
|
3
|
+
module Outlet
|
4
|
+
|
5
|
+
def self.included(base)
|
6
|
+
base.instance_eval do
|
7
|
+
def outlets
|
8
|
+
@outlets ||= {}
|
9
|
+
end
|
10
|
+
end
|
11
|
+
base.extend(ClassMethods)
|
12
|
+
end
|
13
|
+
|
14
|
+
def outlets
|
15
|
+
return @outlets if @outlets
|
16
|
+
@outlets = {}
|
17
|
+
self.class.outlets.each do |name, outlet_class|
|
18
|
+
@outlets[name] = outlet_class.new(@record, @column, @filename, @settings)
|
19
|
+
end
|
20
|
+
@outlets
|
21
|
+
end
|
22
|
+
|
23
|
+
# Copies the file from original path to this outlet path.
|
24
|
+
# prepare( original, result )
|
25
|
+
# This method SHOULD be overridden in the outlet block. Example:
|
26
|
+
# class NewAdapter < Nozzle::Adapter::Base
|
27
|
+
# outlet :thumb do
|
28
|
+
# def prepare( original, result )
|
29
|
+
# `convert #{original} -thumbnail x96 #{result}`
|
30
|
+
# end
|
31
|
+
# end
|
32
|
+
# end
|
33
|
+
# In the example system +convert+ is called to resize the original file
|
34
|
+
# and save it's smaller version in result path.
|
35
|
+
def prepare( original, result )
|
36
|
+
FileUtils.mkdir_p File.dirname(result)
|
37
|
+
FileUtils.cp original, result
|
38
|
+
end
|
39
|
+
|
40
|
+
def prepare!
|
41
|
+
prepare( @record.send(@column).path, path )
|
42
|
+
end
|
43
|
+
|
44
|
+
def cleanup!
|
45
|
+
delete_file_and_folder!( path ) if respond_to?(:version_name)
|
46
|
+
outlets.each{ |name, outlet| outlet.cleanup! }
|
47
|
+
end
|
48
|
+
|
49
|
+
module ClassMethods
|
50
|
+
|
51
|
+
def outlet( name, &block )
|
52
|
+
class_eval <<-RUBY,__FILE__,__LINE__+1
|
53
|
+
def #{name}
|
54
|
+
outlets[:#{name}]
|
55
|
+
end
|
56
|
+
RUBY
|
57
|
+
outlets[name] = create_outlet( name, &block )
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def create_outlet( name, &block )
|
63
|
+
new_outlet = Class.new(self)
|
64
|
+
new_outlet.class_eval <<-RUBY,__FILE__,__LINE__+1
|
65
|
+
def version_name
|
66
|
+
(defined?(super) ? super+'_' : '') + "#{name}"
|
67
|
+
end
|
68
|
+
def filename
|
69
|
+
"#{name}_\#{super}"
|
70
|
+
end
|
71
|
+
RUBY
|
72
|
+
new_outlet.class_eval(&block) if block
|
73
|
+
new_outlet
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
require 'nozzle/adapter/base'
|
2
|
+
|
3
|
+
module Nozzle
|
4
|
+
module Adapter
|
5
|
+
# Installs default or custom adapter to a class.
|
6
|
+
#
|
7
|
+
# class Example
|
8
|
+
# attr_accessor :file, :avatar, :thumb
|
9
|
+
# include Nozzle::Adapter
|
10
|
+
# install_adapter( :file )
|
11
|
+
# install_adapter( :avatar, CustomAdapter )
|
12
|
+
# install_adapter( :thumb, Nozzle::Adapter::Image, :thumb_size => '90x60' )
|
13
|
+
# def save
|
14
|
+
# file_after_save; avatar_after_save; thumb_after_save
|
15
|
+
# end
|
16
|
+
# def destroy
|
17
|
+
# file_after_destroy; avatar_after_destroy; thumb_after_destroy
|
18
|
+
# end
|
19
|
+
# enc
|
20
|
+
#
|
21
|
+
# The class MUST have readers and writers to install corresponding adapters.
|
22
|
+
# +install_adapter+ overrides these methods and saves the originals in
|
23
|
+
# <tt>original_avatar</tt> and <tt>original_avatar=</tt> aliases.
|
24
|
+
# The originals are called to save and load the filename of stored asset.
|
25
|
+
#
|
26
|
+
# The class MUST call +avatar_after_save+ and +avatar_after_destroy+ after
|
27
|
+
# the corresponding events. +avatar_after_save+ does some IO to move
|
28
|
+
# or copy temporary file. +avatar_after_destroy+ deletes the stored file.
|
29
|
+
#
|
30
|
+
# Note: +options+ are only supported when +adapter+ is specified.
|
31
|
+
def install_adapter( column, adapter = nil, options = {} )
|
32
|
+
attr_accessor :"#{column}_adapter"
|
33
|
+
adapter_classes[column] = adapter || Base
|
34
|
+
adapter_options[column] = options
|
35
|
+
|
36
|
+
class_eval <<-RUBY, __FILE__, __LINE__+1
|
37
|
+
unless instance_methods.map(&:to_s).include?('original_#{column}')
|
38
|
+
alias_method :original_#{column}, :#{column}
|
39
|
+
alias_method :original_#{column}=, :#{column}=
|
40
|
+
end
|
41
|
+
|
42
|
+
def #{column}_adapter
|
43
|
+
@#{column}_adapter ||= self.class.adapter_classes[:#{column}].new(
|
44
|
+
self, :#{column}, nil, self.class.adapter_options[:#{column}] )
|
45
|
+
end
|
46
|
+
|
47
|
+
def #{column}
|
48
|
+
#{column}_adapter.load send( :original_#{column} )
|
49
|
+
end
|
50
|
+
|
51
|
+
def #{column}=(value)
|
52
|
+
send( :original_#{column}=, #{column}_adapter.dump(value) )
|
53
|
+
end
|
54
|
+
|
55
|
+
def #{column}_after_save
|
56
|
+
#{column}_adapter.store!
|
57
|
+
end
|
58
|
+
|
59
|
+
def #{column}_after_destroy
|
60
|
+
#{column}
|
61
|
+
#{column}_adapter.cleanup!
|
62
|
+
#{column}_adapter.unlink!
|
63
|
+
end
|
64
|
+
RUBY
|
65
|
+
end
|
66
|
+
|
67
|
+
def adapter_classes # :nodoc:
|
68
|
+
@adapter_classes ||= {}
|
69
|
+
end
|
70
|
+
|
71
|
+
def adapter_options # :nodoc:
|
72
|
+
@adapter_options ||= {}
|
73
|
+
end
|
74
|
+
|
75
|
+
##
|
76
|
+
# :method: avatar
|
77
|
+
# Returns column adapter instance.
|
78
|
+
#
|
79
|
+
# Note: this method and the following 3 methods are dynamically created by
|
80
|
+
# +install_adapter+. These methods will be named according to the column
|
81
|
+
# name specified in first argument of +install_adapter+ call.
|
82
|
+
# This document explains methods created for column named <tt>:avatar</tt>.
|
83
|
+
|
84
|
+
##
|
85
|
+
# :method: avatar=
|
86
|
+
#
|
87
|
+
# :call-seq: avatar=(value)
|
88
|
+
#
|
89
|
+
# Calls initialization routines to save file into class instance.
|
90
|
+
#
|
91
|
+
# The +value+ MUST be File, String, Hash or nil
|
92
|
+
# instance.avatar = File.open('tmp/031337.jpg')
|
93
|
+
# instance.avatar = 'tmp/031337.jpg'
|
94
|
+
# instance.avatar = { :filename => 'Cool file.jpg', :tempfile => cool }
|
95
|
+
# instance.avatar = nil
|
96
|
+
# If +value+ is nil then the stored file is deleted on +avatar_after_save+.
|
97
|
+
|
98
|
+
##
|
99
|
+
# :method: avatar_after_save
|
100
|
+
# Calls Base#store! to move or copy temporary file to it's new store location.
|
101
|
+
|
102
|
+
##
|
103
|
+
# :method: avatar_after_destroy
|
104
|
+
# Calls Base#unlink! to cleanup stored files.
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'nozzle'
|
4
|
+
require 'dm-core'
|
5
|
+
|
6
|
+
module Nozzle
|
7
|
+
module DataMapper
|
8
|
+
include Nozzle::Adapter
|
9
|
+
|
10
|
+
module Property
|
11
|
+
class Filename < ::DataMapper::Property::String
|
12
|
+
length 255
|
13
|
+
def custom?; true; end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def nozzle!( column, adapter = nil )
|
18
|
+
property column, Property::Filename unless properties.named?(column)
|
19
|
+
property :"#{column}_content_type", String, :length => 63 unless properties.named?("#{column}_content_type")
|
20
|
+
property :"#{column}_size", Integer unless properties.named?("#{column}_size")
|
21
|
+
|
22
|
+
install_adapter column, adapter
|
23
|
+
|
24
|
+
after :save, :"#{column}_after_save"
|
25
|
+
after :destroy, :"#{column}_after_destroy"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
DataMapper::Model.append_extensions(Nozzle::DataMapper)
|
data/lib/nozzle.rb
ADDED
data/nozzle.gemspec
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
$LOAD_PATH << File.expand_path('../lib', __FILE__)
|
3
|
+
require 'nozzle/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = 'nozzle'
|
7
|
+
spec.version = Nozzle::VERSION
|
8
|
+
spec.description = 'Attachments for ruby rack'
|
9
|
+
spec.summary = 'A gem to store and serve attachments for ruby rack applications'
|
10
|
+
|
11
|
+
spec.authors = ['Igor Bochkariov']
|
12
|
+
spec.email = ['ujifgc@gmail.com']
|
13
|
+
spec.homepage = 'https://github.com/ujifgc/nozzle'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
|
16
|
+
spec.require_paths = ['lib']
|
17
|
+
spec.files = `git ls-files`.split($/)
|
18
|
+
spec.test_files = spec.files.grep(%r{^test/})
|
19
|
+
|
20
|
+
spec.add_development_dependency 'bundler', '>= 1.3'
|
21
|
+
spec.add_development_dependency 'rake'
|
22
|
+
spec.add_development_dependency 'minitest'
|
23
|
+
end
|
Binary file
|
@@ -0,0 +1,122 @@
|
|
1
|
+
require 'minitest_helper'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
describe Nozzle::Adapter::Base do
|
5
|
+
# these tests are ordered
|
6
|
+
class << self; define_method :test_order do :alpha end; end
|
7
|
+
|
8
|
+
before do
|
9
|
+
class Klass1
|
10
|
+
attr_accessor :avatar, :avatar_content_type, :avatar_size, :asset
|
11
|
+
def save
|
12
|
+
send :avatar_after_save
|
13
|
+
end
|
14
|
+
def destroy
|
15
|
+
send :avatar_after_destroy
|
16
|
+
end
|
17
|
+
end
|
18
|
+
Klass1.send :extend, Nozzle::Adapter
|
19
|
+
Klass1.install_adapter :avatar
|
20
|
+
Klass1.install_adapter :asset
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'should raise ENOENT on non-existing asset' do
|
24
|
+
inst = Klass1.new
|
25
|
+
lambda do
|
26
|
+
inst.avatar = 'test/fixtures/non-existing.jpg'
|
27
|
+
end.must_raise Errno::ENOENT
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'should raise ArgumentError on bad arguments' do
|
31
|
+
inst = Klass1.new
|
32
|
+
lambda do
|
33
|
+
inst.avatar = ['array']
|
34
|
+
end.must_raise ArgumentError
|
35
|
+
lambda do
|
36
|
+
inst.avatar = :symbol
|
37
|
+
end.must_raise ArgumentError
|
38
|
+
end
|
39
|
+
|
40
|
+
it 'should save file into public folder and destroy it' do
|
41
|
+
public_path = 'public/uploads/Klass1/avatar/test-697x960.jpg'
|
42
|
+
|
43
|
+
inst = Klass1.new
|
44
|
+
inst.avatar = 'test/fixtures/test-697x960.jpg'
|
45
|
+
inst.avatar.filename.must_equal 'test-697x960.jpg'
|
46
|
+
|
47
|
+
inst.save
|
48
|
+
inst.avatar.path.must_equal public_path
|
49
|
+
File.exists?(public_path).must_equal true
|
50
|
+
|
51
|
+
inst.save
|
52
|
+
inst.avatar.path.must_equal public_path
|
53
|
+
File.exists?(public_path).must_equal true
|
54
|
+
|
55
|
+
inst.destroy
|
56
|
+
File.exists?(public_path).must_equal false
|
57
|
+
end
|
58
|
+
|
59
|
+
it 'should detect file properties if the properties are available' do
|
60
|
+
inst = Klass1.new
|
61
|
+
inst.avatar = 'test/fixtures/test-697x960.jpg'
|
62
|
+
inst.avatar.content_type.must_equal 'image/jpeg'
|
63
|
+
inst.avatar.size.must_equal File.size('test/fixtures/test-697x960.jpg')
|
64
|
+
|
65
|
+
inst.asset = 'test/fixtures/test-697x960.jpg'
|
66
|
+
inst.asset.content_type.must_equal ''
|
67
|
+
inst.asset.size.must_equal -1
|
68
|
+
end
|
69
|
+
|
70
|
+
it 'should return intermediate tempfile path or stored path' do
|
71
|
+
inst = Klass1.new
|
72
|
+
inst.avatar = 'test/fixtures/test-697x960.jpg'
|
73
|
+
inst.avatar.access_path.must_equal File.expand_path('test/fixtures/test-697x960.jpg')
|
74
|
+
inst.save
|
75
|
+
inst.avatar.access_path.must_equal 'public/uploads/Klass1/avatar/test-697x960.jpg'
|
76
|
+
end
|
77
|
+
|
78
|
+
it 'should respect custom filename' do
|
79
|
+
public_path = 'public/uploads/Klass1/avatar/girl-and-square.jpg'
|
80
|
+
|
81
|
+
inst = Klass1.new
|
82
|
+
inst.avatar = { :tempfile => 'test/fixtures/test-697x960.jpg', :filename => 'girl-and-square.jpg' }
|
83
|
+
inst.avatar.filename.must_equal 'girl-and-square.jpg'
|
84
|
+
|
85
|
+
inst.save
|
86
|
+
inst.avatar.path.must_equal public_path
|
87
|
+
File.exists?(public_path).must_equal true
|
88
|
+
|
89
|
+
inst.destroy
|
90
|
+
File.exists?(public_path).must_equal false
|
91
|
+
end
|
92
|
+
|
93
|
+
it 'should accept string-keyed hash' do
|
94
|
+
public_path = 'public/uploads/Klass1/avatar/girl-and-square.jpg'
|
95
|
+
|
96
|
+
inst = Klass1.new
|
97
|
+
inst.avatar = { 'tempfile' => 'test/fixtures/test-697x960.jpg', 'filename' => 'girl-and-square.jpg' }
|
98
|
+
inst.avatar.filename.must_equal 'girl-and-square.jpg'
|
99
|
+
|
100
|
+
inst.save
|
101
|
+
inst.avatar.path.must_equal public_path
|
102
|
+
File.exists?(public_path).must_equal true
|
103
|
+
|
104
|
+
inst.destroy
|
105
|
+
File.exists?(public_path).must_equal false
|
106
|
+
end
|
107
|
+
|
108
|
+
it 'should destroy files whit was saved before' do
|
109
|
+
public_path = 'public/uploads/Klass1/avatar/test-697x960.jpg'
|
110
|
+
inst = Klass1.new
|
111
|
+
FileUtils.mkdir_p 'public/uploads/Klass1/avatar'
|
112
|
+
FileUtils.cp 'test/fixtures/test-697x960.jpg', public_path
|
113
|
+
inst.instance_eval('@avatar = "test-697x960.jpg"')
|
114
|
+
inst.destroy
|
115
|
+
File.exists?(public_path).must_equal false
|
116
|
+
end
|
117
|
+
|
118
|
+
after do
|
119
|
+
FileUtils.rm_rf 'public'
|
120
|
+
end
|
121
|
+
|
122
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'minitest_helper'
|
2
|
+
require 'fileutils'
|
3
|
+
require 'nozzle/adapter/image'
|
4
|
+
|
5
|
+
describe Nozzle::Adapter::Image do
|
6
|
+
# these tests are ordered
|
7
|
+
class << self; define_method :test_order do :alpha end; end
|
8
|
+
|
9
|
+
before do
|
10
|
+
class Klass3
|
11
|
+
def avatar1; @avatar1; end
|
12
|
+
def avatar1=(value); @avatar1 = value; end
|
13
|
+
def avatar2; @avatar2; end
|
14
|
+
def avatar2=(value); @avatar2 = value; end
|
15
|
+
def save
|
16
|
+
send :avatar1_after_save
|
17
|
+
send :avatar2_after_save
|
18
|
+
end
|
19
|
+
def destroy
|
20
|
+
send :avatar1_after_destroy
|
21
|
+
send :avatar2_after_destroy
|
22
|
+
end
|
23
|
+
end
|
24
|
+
Klass3.send :extend, Nozzle::Adapter
|
25
|
+
Klass3.install_adapter :avatar1, Nozzle::Adapter::Image
|
26
|
+
Klass3.install_adapter :avatar2, Nozzle::Adapter::Image, :thumb_size => '90x60'
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'should have default url' do
|
30
|
+
inst = Klass3.new
|
31
|
+
inst.avatar1.url.must_equal '/images/image_missing.png'
|
32
|
+
inst.avatar2.settings[:thumb_size].must_equal '90x60'
|
33
|
+
inst.avatar1.settings[:thumb_size].must_equal '200x150'
|
34
|
+
|
35
|
+
inst.avatar1 = 'test/fixtures/test-697x960.jpg'
|
36
|
+
inst.avatar2 = 'test/fixtures/test-697x960.jpg'
|
37
|
+
inst.save
|
38
|
+
inst.avatar1.thumb.prepare!
|
39
|
+
`identify #{inst.avatar1.thumb.path}`.must_match /JPEG 109x150/
|
40
|
+
inst.avatar2.thumb.prepare!
|
41
|
+
`identify #{inst.avatar2.thumb.path}`.must_match /JPEG 44x60/
|
42
|
+
end
|
43
|
+
|
44
|
+
after do
|
45
|
+
FileUtils.rm_rf 'public'
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
require 'minitest_helper'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
describe Nozzle::Adapter::Outlet do
|
5
|
+
# these tests are ordered
|
6
|
+
class << self; define_method :test_order do :alpha end; end
|
7
|
+
|
8
|
+
before do
|
9
|
+
class Klass2
|
10
|
+
def avatar
|
11
|
+
@avatar
|
12
|
+
end
|
13
|
+
def avatar=(value)
|
14
|
+
@avatar = value
|
15
|
+
end
|
16
|
+
def save
|
17
|
+
send :avatar_after_save
|
18
|
+
end
|
19
|
+
def destroy
|
20
|
+
send :avatar_after_destroy
|
21
|
+
end
|
22
|
+
end
|
23
|
+
Klass2.send :extend, Nozzle::Adapter
|
24
|
+
class BaseOutlet < Nozzle::Adapter::Base
|
25
|
+
def filename
|
26
|
+
"#{Time.now.strftime('%Y%m%d')}_#{super}"
|
27
|
+
end
|
28
|
+
|
29
|
+
outlet :thumb do
|
30
|
+
def filename
|
31
|
+
'ava_' + super
|
32
|
+
end
|
33
|
+
def prepare( original, result )
|
34
|
+
`convert #{original} -thumbnail x96 #{result}`
|
35
|
+
end
|
36
|
+
outlet :mini
|
37
|
+
end
|
38
|
+
outlet :big do
|
39
|
+
def relative_folder
|
40
|
+
File.join 'big', @model.to_s
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
Klass2.install_adapter :avatar, BaseOutlet
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'should install an adapter with custom filename and outlets' do
|
48
|
+
date = Time.now.strftime('%Y%m%d')
|
49
|
+
inst = Klass2.new
|
50
|
+
inst.avatar = 'test/fixtures/test-697x960.jpg'
|
51
|
+
inst.save
|
52
|
+
inst.avatar.filename.must_equal "#{date}_test-697x960.jpg"
|
53
|
+
inst.avatar.thumb.filename.must_equal "ava_#{date}_test-697x960.jpg"
|
54
|
+
inst.avatar.big.path.must_equal "public/uploads/big/Klass2/big_#{date}_test-697x960.jpg"
|
55
|
+
inst.avatar.thumb.mini.path.must_equal "public/uploads/Klass2/avatar/mini_ava_#{date}_test-697x960.jpg"
|
56
|
+
inst.avatar.respond_to?(:version_name).must_equal false, 'original adapter must not have version name'
|
57
|
+
inst.avatar.thumb.version_name.must_equal 'thumb'
|
58
|
+
inst.avatar.thumb.mini.version_name.must_equal 'thumb_mini'
|
59
|
+
|
60
|
+
inst.avatar.thumb.prepare!
|
61
|
+
`identify #{inst.avatar.thumb.path}`.must_match /JPEG 70x96/
|
62
|
+
inst.avatar.big.prepare!
|
63
|
+
`identify #{inst.avatar.big.path}`.must_match /JPEG 697x960/
|
64
|
+
|
65
|
+
inst.avatar.cleanup!
|
66
|
+
File.exists?(inst.avatar.path).must_equal true, 'outlet must not cleanup the original'
|
67
|
+
File.exists?(inst.avatar.thumb.path).must_equal false, 'outlet must cleanup its cache'
|
68
|
+
File.exists?(inst.avatar.big.path).must_equal false
|
69
|
+
File.exists?(inst.avatar.big.system_path).must_equal false, 'outlet must cleanup its folders'
|
70
|
+
|
71
|
+
inst.avatar.big.prepare!
|
72
|
+
`identify #{inst.avatar.big.path}`.must_match /JPEG 697x960/
|
73
|
+
a,b,a1,b1 = [ inst.avatar.path, inst.avatar.big.path, inst.avatar.system_path, inst.avatar.big.system_path ]
|
74
|
+
inst.destroy
|
75
|
+
File.exists?(a).must_equal false
|
76
|
+
File.exists?(b).must_equal false
|
77
|
+
File.exists?(a1).must_equal false
|
78
|
+
File.exists?(b1).must_equal false
|
79
|
+
end
|
80
|
+
|
81
|
+
it 'should move temporary file' do
|
82
|
+
temporary_path = '/tmp/test-697x960.jpg'
|
83
|
+
FileUtils.cp 'test/fixtures/test-697x960.jpg', temporary_path
|
84
|
+
inst1 = Klass2.new
|
85
|
+
inst1.avatar = temporary_path
|
86
|
+
inst1.save
|
87
|
+
File.exists?(inst1.avatar.path).must_equal true
|
88
|
+
File.exists?(temporary_path).must_equal false
|
89
|
+
end
|
90
|
+
|
91
|
+
it 'should copy non-temporary file' do
|
92
|
+
permanent_path = 'test/fixtures/test-697x960.jpg'
|
93
|
+
inst1 = Klass2.new
|
94
|
+
inst1.avatar = permanent_path
|
95
|
+
inst1.save
|
96
|
+
File.exists?(inst1.avatar.path).must_equal true
|
97
|
+
File.exists?(permanent_path).must_equal true
|
98
|
+
end
|
99
|
+
|
100
|
+
after do
|
101
|
+
FileUtils.rm_rf 'public'
|
102
|
+
end
|
103
|
+
|
104
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'minitest_helper'
|
2
|
+
|
3
|
+
describe Nozzle::Adapter do
|
4
|
+
|
5
|
+
before do
|
6
|
+
class Klass0
|
7
|
+
def avatar
|
8
|
+
@avatar
|
9
|
+
end
|
10
|
+
def avatar=(value)
|
11
|
+
@avatar = value
|
12
|
+
end
|
13
|
+
end
|
14
|
+
Klass0.send :extend, Nozzle::Adapter
|
15
|
+
Klass0.install_adapter :avatar
|
16
|
+
end
|
17
|
+
|
18
|
+
it 'should be able to install itself' do
|
19
|
+
Klass0.adapter_classes.must_equal :avatar => Nozzle::Adapter::Base
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'should register instance methods' do
|
23
|
+
@inst = Klass0.new
|
24
|
+
@inst.respond_to?(:avatar_adapter).must_equal true
|
25
|
+
@inst.respond_to?(:avatar).must_equal true
|
26
|
+
@inst.respond_to?(:avatar=).must_equal true
|
27
|
+
@inst.respond_to?(:avatar_after_save).must_equal true
|
28
|
+
@inst.respond_to?(:avatar_after_destroy).must_equal true
|
29
|
+
@inst.avatar_adapter.class.must_equal Nozzle::Adapter::Base
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'should register instance methods one and only one time' do
|
33
|
+
Klass0.install_adapter :avatar
|
34
|
+
Klass0.install_adapter :avatar
|
35
|
+
@inst = Klass0.new
|
36
|
+
@inst.avatar.class.must_equal Nozzle::Adapter::Base
|
37
|
+
@inst.original_avatar.class.wont_equal Nozzle::Adapter::Base
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
data/test/nozzle_test.rb
ADDED
metadata
ADDED
@@ -0,0 +1,114 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: nozzle
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Igor Bochkariov
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2013-06-24 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - '>='
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.3'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - '>='
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.3'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '>='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '>='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: minitest
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - '>='
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
description: Attachments for ruby rack
|
56
|
+
email:
|
57
|
+
- ujifgc@gmail.com
|
58
|
+
executables: []
|
59
|
+
extensions: []
|
60
|
+
extra_rdoc_files: []
|
61
|
+
files:
|
62
|
+
- .gitignore
|
63
|
+
- .travis.yml
|
64
|
+
- Gemfile
|
65
|
+
- LICENSE.txt
|
66
|
+
- README.md
|
67
|
+
- Rakefile
|
68
|
+
- lib/nozzle.rb
|
69
|
+
- lib/nozzle/adapter.rb
|
70
|
+
- lib/nozzle/adapter/base.rb
|
71
|
+
- lib/nozzle/adapter/image.rb
|
72
|
+
- lib/nozzle/adapter/outlet.rb
|
73
|
+
- lib/nozzle/datamapper.rb
|
74
|
+
- lib/nozzle/version.rb
|
75
|
+
- nozzle.gemspec
|
76
|
+
- test/fixtures/test-697x960.jpg
|
77
|
+
- test/minitest_helper.rb
|
78
|
+
- test/nozzle_adapter_base_test.rb
|
79
|
+
- test/nozzle_adapter_image_test.rb
|
80
|
+
- test/nozzle_adapter_outlet_test.rb
|
81
|
+
- test/nozzle_adapter_test.rb
|
82
|
+
- test/nozzle_test.rb
|
83
|
+
homepage: https://github.com/ujifgc/nozzle
|
84
|
+
licenses:
|
85
|
+
- MIT
|
86
|
+
metadata: {}
|
87
|
+
post_install_message:
|
88
|
+
rdoc_options: []
|
89
|
+
require_paths:
|
90
|
+
- lib
|
91
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
92
|
+
requirements:
|
93
|
+
- - '>='
|
94
|
+
- !ruby/object:Gem::Version
|
95
|
+
version: '0'
|
96
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
97
|
+
requirements:
|
98
|
+
- - '>='
|
99
|
+
- !ruby/object:Gem::Version
|
100
|
+
version: '0'
|
101
|
+
requirements: []
|
102
|
+
rubyforge_project:
|
103
|
+
rubygems_version: 2.0.3
|
104
|
+
signing_key:
|
105
|
+
specification_version: 4
|
106
|
+
summary: A gem to store and serve attachments for ruby rack applications
|
107
|
+
test_files:
|
108
|
+
- test/fixtures/test-697x960.jpg
|
109
|
+
- test/minitest_helper.rb
|
110
|
+
- test/nozzle_adapter_base_test.rb
|
111
|
+
- test/nozzle_adapter_image_test.rb
|
112
|
+
- test/nozzle_adapter_outlet_test.rb
|
113
|
+
- test/nozzle_adapter_test.rb
|
114
|
+
- test/nozzle_test.rb
|