trailblazer 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +1 -1
  3. data/.travis.yml +5 -0
  4. data/CHANGES.md +3 -0
  5. data/Gemfile +4 -0
  6. data/README.md +417 -16
  7. data/Rakefile +14 -0
  8. data/THOUGHTS +12 -0
  9. data/TODO.md +6 -0
  10. data/doc/Trb-The-Stack.png +0 -0
  11. data/doc/trb.jpg +0 -0
  12. data/gemfiles/Gemfile.rails +7 -0
  13. data/gemfiles/Gemfile.rails.lock +99 -0
  14. data/lib/trailblazer.rb +2 -0
  15. data/lib/trailblazer/autoloading.rb +5 -0
  16. data/lib/trailblazer/operation.rb +124 -0
  17. data/lib/trailblazer/operation/controller.rb +76 -0
  18. data/lib/trailblazer/operation/crud.rb +61 -0
  19. data/lib/trailblazer/operation/representer.rb +18 -0
  20. data/lib/trailblazer/operation/responder.rb +24 -0
  21. data/lib/trailblazer/operation/uploaded_file.rb +77 -0
  22. data/lib/trailblazer/operation/worker.rb +96 -0
  23. data/lib/trailblazer/version.rb +1 -1
  24. data/test/crud_test.rb +115 -0
  25. data/test/fixtures/apotomo.png +0 -0
  26. data/test/fixtures/cells.png +0 -0
  27. data/test/operation_test.rb +334 -0
  28. data/test/rails/controller_test.rb +175 -0
  29. data/test/rails/fake_app/app-cells/.gitkeep +0 -0
  30. data/test/rails/fake_app/cells.rb +21 -0
  31. data/test/rails/fake_app/config.rb +3 -0
  32. data/test/rails/fake_app/controllers.rb +101 -0
  33. data/test/rails/fake_app/models.rb +13 -0
  34. data/test/rails/fake_app/rails_app.rb +57 -0
  35. data/test/rails/fake_app/song/operations.rb +63 -0
  36. data/test/rails/fake_app/views/bands/show.html.erb +1 -0
  37. data/test/rails/fake_app/views/songs/new.html.erb +1 -0
  38. data/test/rails/test_helper.rb +4 -0
  39. data/test/responder_test.rb +77 -0
  40. data/test/test_helper.rb +15 -0
  41. data/test/uploaded_file_test.rb +85 -0
  42. data/test/worker_test.rb +116 -0
  43. data/trailblazer.gemspec +10 -2
  44. metadata +160 -23
@@ -0,0 +1,24 @@
1
+ module Trailblazer::Operation::Responder
2
+ # TODO: test me.
3
+ def self.included(base)
4
+ base.extend ClassMethods
5
+ end
6
+
7
+ module ClassMethods
8
+ def model_name
9
+ model_class.model_name
10
+ end
11
+ end
12
+
13
+ extend Forwardable
14
+ def_delegators :@model, :to_param, :destroyed?, :persisted?
15
+
16
+ def errors
17
+ return [] if @valid
18
+ [1]
19
+ end
20
+
21
+ def to_json(*)
22
+ self.class.representer_class.new(model).to_json
23
+ end
24
+ end
@@ -0,0 +1,77 @@
1
+ require 'trailblazer/operation'
2
+ require 'action_dispatch/http/upload'
3
+ require 'tempfile'
4
+
5
+ module Trailblazer
6
+ # TODO: document:
7
+ # to_hash
8
+ # from_hash
9
+ # initialize/tmp_dir
10
+ class Operation::UploadedFile
11
+ def initialize(uploaded, options={})
12
+ @uploaded = uploaded
13
+ @options = options
14
+ @tmp_dir = options[:tmp_dir]
15
+ end
16
+
17
+ def to_hash
18
+ path = persist!
19
+
20
+ hash = {
21
+ :filename => @uploaded.original_filename,
22
+ :type => @uploaded.content_type,
23
+ :tempfile_path => path
24
+ }
25
+
26
+ cleanup!
27
+
28
+ hash
29
+ end
30
+
31
+ # Returns a ActionDispatch::Http::UploadedFile as if the upload was in the same request.
32
+ def self.from_hash(hash)
33
+ suffix = File.extname(hash[:filename])
34
+
35
+ # we need to create a Tempfile to make Http::UploadedFile work.
36
+ tmp = Tempfile.new(["bla", suffix]) # always force file suffix to avoid problems with imagemagick etc.
37
+ file = File.open(hash[:tempfile_path])# doesn't close automatically :( # fixme: introduce strategy (Tempfile:=>slow, File:=> hopefully less memory footprint)
38
+ tmp.write(file.read) # DISCUSS: We need Tempfile.new(<File>) to avoid this slow and memory-consuming mechanics.
39
+
40
+ file.close # TODO: can we test that?
41
+ File.unlink(file)
42
+
43
+ ActionDispatch::Http::UploadedFile.new(hash.merge(:tempfile => tmp))
44
+ end
45
+
46
+ private
47
+ attr_reader :tmp_dir
48
+
49
+ # convert Tempfile from Rails upload into persistent "temp" file so it is available in workers.
50
+ def persist!
51
+ path = @uploaded.path # original Tempfile path (from Rails).
52
+ path = path_with_tmp_dir(path)
53
+
54
+ path = path + "_trailblazer_upload"
55
+
56
+ FileUtils.mv(@uploaded.path, path) # move Rails upload file into persistent `path`.
57
+ path
58
+ end
59
+
60
+ def path_with_tmp_dir(path)
61
+ return path unless tmp_dir # if tmp_dir set, create path in it.
62
+
63
+ @with_tmp_dir = Tempfile.new(File.basename(path), tmp_dir)
64
+ @with_tmp_dir.path # use Tempfile to create nested dirs (os-dependent.)
65
+ end
66
+
67
+ def delete!(file)
68
+ file.close
69
+ file.unlink # the Rails uploaded file is already unlinked since moved.
70
+ end
71
+
72
+ def cleanup!
73
+ delete!(@uploaded.tempfile) if @uploaded.respond_to?(:tempfile) # this is Rails' uploaded file, not sure if we need to do that. in 3.2, we don't have UploadedFile#close, yet.
74
+ delete!(@with_tmp_dir) if @with_tmp_dir # we used that file to create a tmp file path below tmp_dir.
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,96 @@
1
+ require 'sidekiq/worker'
2
+ # require 'active_support/hash_with_indifferent_access'
3
+ require 'active_support/core_ext/hash/indifferent_access'
4
+
5
+
6
+ class Trailblazer::Operation
7
+ # only kicks in when Operation::run, #run will still do it real-time
8
+ module Worker
9
+ def self.included(base)
10
+ base.send(:include, Sidekiq::Worker) # TODO: this will work with any bg gem.
11
+ base.extend(ClassMethods)
12
+ end
13
+
14
+ module ClassMethods
15
+ def run(params)
16
+ if background?
17
+ return perform_async(serializable(params))
18
+ end
19
+
20
+ new.run(params)
21
+ end
22
+
23
+ private
24
+ def background? # TODO: make configurable.
25
+ true
26
+ # if Rails.env == "production" or Rails.env == "staging"
27
+ end
28
+
29
+ def serializable(params)
30
+ params # this is where we convert file uloads into Trailblazer::UploadedFile, etc. soon.
31
+ end
32
+ end
33
+
34
+
35
+ # called from Sidekiq.
36
+ def perform(params)
37
+ # the serialized params hash from Sidekiq contains a Op::UploadedFile hash.
38
+
39
+ # the following code is basically what happens in a controller.
40
+ # this is a bug in Rails, it doesn't work without requiring as/hash/ina
41
+ # params = ActiveSupport::HashWithIndifferentAccess.new_from_hash_copying_default(params) # TODO: this might make it ultra-slow as Reform converts it back to strings.
42
+ params = params.with_indifferent_access
43
+
44
+ run(deserializable(params))
45
+ end
46
+
47
+ private
48
+ def deserializable(params)
49
+ params # this is where we convert file uloads into Trailblazer::UploadedFile, etc. soon.
50
+ end
51
+
52
+
53
+ # Overrides ::serializable and #deserializable and handles file properties from the Contract schema.
54
+ module FileMarshaller
55
+ # NOTE: this is WIP and the implementation will be more understandable and performant soon.
56
+ def self.included(base)
57
+ base.extend ClassMethods
58
+ end
59
+
60
+
61
+ private
62
+ module ClassMethods
63
+ def file_marshaller_representer
64
+ @file_marshaller_representer ||= contract_class.schema.apply do |dfn|
65
+ dfn.delete!(:prepare)
66
+
67
+ dfn.merge!(
68
+ :getter => lambda { |*| self[dfn.name.to_sym] },
69
+ :setter => lambda { |fragment, *| self[dfn.name.to_s] = fragment }
70
+ ) # FIXME: allow both sym and str.
71
+
72
+ dfn.merge!(:class => Hash) and next if dfn[:form] # nested properties need a class for deserialization.
73
+ next unless dfn[:file]
74
+
75
+ # TODO: where do we set /tmp/uploads?
76
+ dfn.merge!(
77
+ :serialize => lambda { |file, *| Trailblazer::Operation::UploadedFile.new(file, :tmp_dir => "/tmp/uploads").to_hash },
78
+ :deserialize => lambda { |object, hash, *| Trailblazer::Operation::UploadedFile.from_hash(hash) },
79
+ :class => Hash
80
+ )
81
+ end
82
+ end
83
+
84
+ def serializable(params)
85
+ file_marshaller_representer.new(params).to_hash
86
+ end
87
+ end
88
+
89
+ # todo: do with_indifferent_access in #deserialize and call super here.
90
+ def deserializable(hash)
91
+ # self.class.file_marshaller_representer.new({}).extend(Representable::Debug).from_hash(hash)
92
+ self.class.file_marshaller_representer.new({}.with_indifferent_access).from_hash(hash)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -1,3 +1,3 @@
1
1
  module Trailblazer
2
- VERSION = "0.0.1"
2
+ VERSION = "0.1.0"
3
3
  end
data/test/crud_test.rb ADDED
@@ -0,0 +1,115 @@
1
+ require 'test_helper'
2
+ require 'trailblazer/operation'
3
+
4
+ class CrudTest < MiniTest::Spec
5
+ Song = Struct.new(:title, :id) do
6
+ class << self
7
+ attr_accessor :find_result # TODO: eventually, replace with AR test.
8
+
9
+ def find(id)
10
+ find_result
11
+ end
12
+ end
13
+ end
14
+
15
+ class CreateOperation < Trailblazer::Operation
16
+ include CRUD
17
+ model Song
18
+ action :create
19
+
20
+ contract do
21
+ property :title
22
+ validates :title, presence: true
23
+ end
24
+
25
+ def process(params)
26
+ validate(params[:song]) do |f|
27
+ f.sync
28
+ end
29
+ end
30
+ end
31
+
32
+
33
+ # creates model for you.
34
+ it { CreateOperation[song: {title: "Blue Rondo a la Turk"}].model.title.must_equal "Blue Rondo a la Turk" }
35
+ # exposes #model.
36
+ it { CreateOperation[song: {title: "Blue Rondo a la Turk"}].model.must_be_instance_of Song }
37
+
38
+ class ModifyingCreateOperation < CreateOperation
39
+ def process(params)
40
+ model.instance_eval { def genre; "Punkrock"; end }
41
+
42
+ validate(params[:song]) do |f|
43
+ f.sync
44
+ end
45
+ end
46
+ end
47
+
48
+ # lets you modify model.
49
+ it { ModifyingCreateOperation[song: {title: "Blue Rondo a la Turk"}].model.title.must_equal "Blue Rondo a la Turk" }
50
+ it { ModifyingCreateOperation[song: {title: "Blue Rondo a la Turk"}].model.genre.must_equal "Punkrock" }
51
+
52
+ # Update
53
+ class UpdateOperation < CreateOperation
54
+ action :update
55
+ end
56
+
57
+ # finds model and updates.
58
+ it do
59
+ song = CreateOperation[song: {title: "Anchor End"}].model
60
+ Song.find_result = song
61
+
62
+ UpdateOperation[id: song.id, song: {title: "The Rip"}].model.title.must_equal "The Rip"
63
+ song.title.must_equal "The Rip"
64
+ end
65
+
66
+ # Find == Update
67
+ class FindOperation < CreateOperation
68
+ action :find
69
+ end
70
+
71
+ # finds model and updates.
72
+ it do
73
+ song = CreateOperation[song: {title: "Anchor End"}].model
74
+ Song.find_result = song
75
+
76
+ FindOperation[id: song.id, song: {title: "The Rip"}].model.title.must_equal "The Rip"
77
+ song.title.must_equal "The Rip"
78
+ end
79
+
80
+
81
+ class DefaultCreateOperation < Trailblazer::Operation
82
+ include CRUD
83
+ model Song
84
+
85
+ def process(params)
86
+ self
87
+ end
88
+ end
89
+
90
+ # uses :create as default if not set via ::action.
91
+ it { DefaultCreateOperation[{}].model.must_equal Song.new }
92
+
93
+ # model Song, :action
94
+ class ModelUpdateOperation < CreateOperation
95
+ model Song, :update
96
+ end
97
+
98
+ # allows ::model, :action.
99
+ it do
100
+ Song.find_result = song = Song.new
101
+ ModelUpdateOperation[{id: 1, song: {title: "Mercy Day For Mr. Vengeance"}}].model.must_equal song
102
+ end
103
+
104
+ # no call to ::model raises error.
105
+ class NoModelOperation < Trailblazer::Operation
106
+ include CRUD
107
+
108
+ def process(params)
109
+ self
110
+ end
111
+ end
112
+
113
+ # uses :create as default if not set via ::action.
114
+ it { assert_raises(RuntimeError){ NoModelOperation[{}] } }
115
+ end
Binary file
Binary file
@@ -0,0 +1,334 @@
1
+ require 'test_helper'
2
+
3
+ module Comparable
4
+ # only used for test.
5
+ def ==(b)
6
+ self.class == b.class
7
+ end
8
+ end
9
+
10
+
11
+ class OperationRunTest < MiniTest::Spec
12
+ class Operation < Trailblazer::Operation
13
+ # allow providing your own contract.
14
+ self.contract_class = class Contract
15
+ def initialize(*)
16
+ end
17
+ def validate(params)
18
+ return false if params == false # used in ::[] with exception test.
19
+ "local #{params}"
20
+ end
21
+
22
+ def errors
23
+ Struct.new(:to_s).new("Op just calls #to_s on Errors!")
24
+ end
25
+
26
+ include Comparable
27
+ self
28
+ end
29
+
30
+ def process(params)
31
+ model = Object
32
+ validate(params, model)
33
+ end
34
+ end
35
+
36
+ let (:operation) { Operation.new.extend(Comparable) }
37
+
38
+ # contract is inferred from self::contract_class.
39
+ it { Operation.run(true).must_equal ["local true", operation] }
40
+
41
+ # return operation when ::call
42
+ it { Operation.call(true).must_equal operation }
43
+ it { Operation[true].must_equal operation }
44
+
45
+ # ::[] raises exception when invalid.
46
+ it do
47
+ exception = assert_raises(Trailblazer::Operation::InvalidContract) { Operation[false] }
48
+ exception.message.must_equal "Op just calls #to_s on Errors!"
49
+ end
50
+
51
+ # ::run without block returns result set.
52
+ it { Operation.run(true).must_equal ["local true", operation] }
53
+ it { Operation.run(false).must_equal [false, operation] }
54
+
55
+ # ::run with block returns operation.
56
+ # valid executes block.
57
+ it "block" do
58
+ outcome = nil
59
+ res = Operation.run(true) do
60
+ outcome = "true"
61
+ end
62
+
63
+ outcome.must_equal "true" # block was executed.
64
+ res.must_equal operation
65
+ end
66
+
67
+ # invalid doesn't execute block.
68
+ it "block, invalid" do
69
+ outcome = nil
70
+ res = Operation.run(false) do
71
+ outcome = "true"
72
+ end
73
+
74
+ outcome.must_equal nil # block was _not_ executed.
75
+ res.must_equal operation
76
+ end
77
+
78
+ # block yields operation
79
+ it do
80
+ outcome = nil
81
+ res = Operation.run(true) do |op|
82
+ outcome = op
83
+ end
84
+
85
+ outcome.must_equal operation # block was executed.
86
+ res.must_equal operation
87
+ end
88
+
89
+ # Operation#contract returns @contract
90
+ let (:contract) { Operation::Contract.new }
91
+ it { Operation[true].contract.must_equal contract }
92
+ end
93
+
94
+
95
+ class OperationTest < MiniTest::Spec
96
+ class Operation < Trailblazer::Operation
97
+ def process(params)
98
+ validate(Object, params)
99
+ end
100
+ end
101
+
102
+ # contract is retrieved from ::contract_class.
103
+ it { assert_raises(NoMethodError) { Operation.run({}) } } # TODO: if you call #validate without defining a contract, the error is quite cryptic.
104
+
105
+ # no #process method defined.
106
+ # DISCUSS: not sure if we need that.
107
+ # class OperationWithoutProcessMethod < Trailblazer::Operation
108
+ # end
109
+
110
+ # it { OperationWithoutProcessMethod[{}].must_be_kind_of OperationWithoutProcessMethod }
111
+
112
+ # #process and no validate.
113
+ class OperationWithoutValidateCall < Trailblazer::Operation
114
+ def process(params)
115
+ params || invalid!(params)
116
+ end
117
+ end
118
+
119
+ # ::run
120
+ it { OperationWithoutValidateCall.run(Object).must_equal [true, Object] }
121
+ # ::[]
122
+ it { OperationWithoutValidateCall[Object].must_equal(Object) }
123
+ # ::run with invalid!
124
+ it { OperationWithoutValidateCall.run(nil).must_equal [false, nil] }
125
+ # ::run with block, invalid
126
+ it do
127
+ OperationWithoutValidateCall.run(false) { @outcome = "true" }.must_equal false
128
+ @outcome.must_equal nil
129
+ end
130
+ # ::run with block, valid
131
+ it do
132
+ OperationWithoutValidateCall.run(true) { @outcome = "true" }.must_equal true
133
+ @outcome.must_equal "true"
134
+ end
135
+
136
+ # #validate yields contract when valid
137
+ class OperationWithValidateBlock < Trailblazer::Operation
138
+ self.contract_class = class Contract
139
+ def initialize(*)
140
+ end
141
+
142
+ def validate(params)
143
+ params
144
+ end
145
+ self
146
+ end
147
+
148
+ def process(params)
149
+ validate(params, Object.new) do |c|
150
+ @secret_contract = c
151
+ end
152
+ end
153
+
154
+ attr_reader :secret_contract
155
+ end
156
+
157
+ it { OperationWithValidateBlock.run(false).last.secret_contract.must_equal nil }
158
+ it('zzz') { OperationWithValidateBlock[true].secret_contract.must_equal OperationWithValidateBlock.contract_class.new.extend(Comparable) }
159
+
160
+ # manually setting @valid
161
+ class OperationWithManualValid < Trailblazer::Operation
162
+ def process(params)
163
+ @valid = false
164
+ params
165
+ end
166
+ end
167
+
168
+ # ::run
169
+ it { OperationWithManualValid.run(Object).must_equal [false, Object] }
170
+ # ::[]
171
+ it { OperationWithManualValid[Object].must_equal(Object) }
172
+
173
+
174
+ # re-assign params
175
+ class OperationReassigningParams < Trailblazer::Operation
176
+ def process(params)
177
+ params = params[:title]
178
+ params
179
+ end
180
+ end
181
+
182
+ # ::run
183
+ it { OperationReassigningParams.run({:title => "Day Like This"}).must_equal [true, "Day Like This"] }
184
+
185
+
186
+ # #invalid!(result)
187
+ class OperationCallingInvalid < Trailblazer::Operation
188
+ def process(params)
189
+ return 1 if params
190
+ invalid!(2)
191
+ end
192
+ end
193
+
194
+ it { OperationCallingInvalid.run(true).must_equal [true, 1] }
195
+ it { OperationCallingInvalid.run(nil).must_equal [false, 2] }
196
+
197
+ # #invalid! without result defaults to operation instance.
198
+ class OperationCallingInvalidWithoutResult < Trailblazer::Operation
199
+ include Comparable
200
+ def process(params)
201
+ invalid!
202
+ end
203
+ end
204
+
205
+ it { OperationCallingInvalidWithoutResult.run(true).must_equal [false, OperationCallingInvalidWithoutResult.new] }
206
+
207
+
208
+ # calling return from #validate block leaves result true.
209
+ class OperationUsingReturnInValidate < Trailblazer::Operation
210
+ self.contract_class = class Contract
211
+ def initialize(*)
212
+ end
213
+ def validate(params)
214
+ params
215
+ end
216
+ self
217
+ end
218
+
219
+ def process(params)
220
+ validate(params, Object) do
221
+ return 1
222
+ end
223
+ 2
224
+ end
225
+ end
226
+
227
+ it { OperationUsingReturnInValidate.run(true).must_equal [true, 1] }
228
+ it { OperationUsingReturnInValidate.run(false).must_equal [false, 2] }
229
+
230
+
231
+ # unlimited arguments for ::run and friends.
232
+ class OperationReceivingLottaArguments < Trailblazer::Operation
233
+ def process(model, params)
234
+ [model, params]
235
+ end
236
+ end
237
+
238
+ it { OperationReceivingLottaArguments.run(Object, {}).must_equal([true, [Object, {}]]) }
239
+
240
+
241
+ # TODO: experimental.
242
+ # ::present to avoid running #validate.
243
+ class ContractOnlyOperation < Trailblazer::Operation
244
+ self.contract_class = class Contract
245
+ def initialize(model)
246
+ @_model = model
247
+ end
248
+ attr_reader :_model
249
+ self
250
+ end
251
+
252
+ def process(params)
253
+ @object = Object # arbitrary init code.
254
+
255
+ validate(params, Object) do
256
+ raise "this should not be run."
257
+ end
258
+ end
259
+ end
260
+
261
+ it { ContractOnlyOperation.present({}).contract._model.must_equal Object }
262
+
263
+ end
264
+
265
+ class OperationBuilderTest < MiniTest::Spec
266
+ class Operation < Trailblazer::Operation
267
+ def process(params)
268
+ "operation"
269
+ end
270
+
271
+ class Sub < self
272
+ def process(params)
273
+ "sub:operation"
274
+ end
275
+ end
276
+
277
+ builds do |params|
278
+ Sub if params[:sub]
279
+ end
280
+ end
281
+
282
+ it { Operation.run({}).last.must_equal "operation" }
283
+ it { Operation.run({sub: true}).last.must_equal "sub:operation" }
284
+
285
+ it { Operation[{}].must_equal "operation" }
286
+ it { Operation[{sub: true}].must_equal "sub:operation" }
287
+ end
288
+
289
+ # ::contract builds Reform::Form class
290
+ class OperationInheritanceTest < MiniTest::Spec
291
+ class Operation < Trailblazer::Operation
292
+ contract do
293
+ property :title
294
+ property :band
295
+
296
+ # TODO/DISCUSS: this is needed in order to "handle" the anon forms. but in Trb, that
297
+ # doesn't really matter as AM is automatically included?
298
+ def self.name
299
+ "Song"
300
+ end
301
+ end
302
+
303
+ class JSON < self
304
+ # inherit Contract
305
+ contract do
306
+ property :genre, validates: {presence: true}
307
+ property :band, virtual: true
308
+ end
309
+ end
310
+ end
311
+
312
+ # inherits subclassed Contract.
313
+ it { Operation.contract_class.wont_equal Operation::JSON.contract_class }
314
+
315
+ it do
316
+ form = Operation.contract_class.new(OpenStruct.new)
317
+ form.validate({})#.must_equal true
318
+ form.errors.to_s.must_equal "{}"
319
+
320
+ form = Operation::JSON.contract_class.new(OpenStruct.new)
321
+ form.validate({})#.must_equal true
322
+ form.errors.to_s.must_equal "{:genre=>[\"can't be blank\"]}"
323
+ end
324
+
325
+ # allows overriding options
326
+ it do
327
+ form = Operation::JSON.contract_class.new(song = OpenStruct.new)
328
+ form.validate({genre: "Punkrock", band: "Osker"}).must_equal true
329
+ form.sync
330
+
331
+ song.genre.must_equal "Punkrock"
332
+ song.band.must_equal nil
333
+ end
334
+ end