trailblazer 0.0.1 → 0.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.
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