trailblazer-macro-contract 2.1.0.beta2
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 +7 -0
- data/.gitignore +18 -0
- data/.rubocop.yml +6 -0
- data/.rubocop_todo.yml +322 -0
- data/.travis.yml +15 -0
- data/CHANGES.md +39 -0
- data/COMM-LICENSE +91 -0
- data/Gemfile +21 -0
- data/LICENSE.txt +9 -0
- data/README.md +195 -0
- data/Rakefile +17 -0
- data/lib/trailblazer/macro/contract.rb +5 -0
- data/lib/trailblazer/macro/contract/version.rb +7 -0
- data/lib/trailblazer/operation/contract.rb +61 -0
- data/lib/trailblazer/operation/persist.rb +14 -0
- data/lib/trailblazer/operation/validate.rb +74 -0
- data/test/docs/contract_test.rb +439 -0
- data/test/docs/dry_test.rb +31 -0
- data/test/operation/contract_test.rb +84 -0
- data/test/operation/persist_test.rb +51 -0
- data/test/test_helper.rb +40 -0
- data/trailblazer-macro-contract.gemspec +39 -0
- metadata +270 -0
data/Gemfile
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
|
3
|
+
# Specify your gem's dependencies in trailblazer.gemspec
|
4
|
+
gemspec
|
5
|
+
|
6
|
+
gem "reform-rails"
|
7
|
+
gem "activesupport"
|
8
|
+
|
9
|
+
gem "reform"
|
10
|
+
gem "multi_json"
|
11
|
+
|
12
|
+
gem "dry-auto_inject"
|
13
|
+
gem "dry-matcher"
|
14
|
+
gem "dry-validation"
|
15
|
+
|
16
|
+
# gem "trailblazer-operation", github: "trailblazer/trailblazer-operation"
|
17
|
+
# gem "trailblazer-macro", github: "trailblazer/trailblazer-macro"
|
18
|
+
|
19
|
+
gem "minitest-line"
|
20
|
+
|
21
|
+
gem "rubocop", require: false
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,9 @@
|
|
1
|
+
Copyright (c) Nick Sutterer
|
2
|
+
|
3
|
+
Trailblazer is an Open Source project licensed under the terms of
|
4
|
+
the LGPLv3 license. Please see <http://www.gnu.org/licenses/lgpl-3.0.html>
|
5
|
+
for license text.
|
6
|
+
|
7
|
+
Trailblazer Enterprise has a commercial-friendly license allowing private forks
|
8
|
+
and modifications of Trailblazer. Please see http://trailblazer.to/enterprise/ for
|
9
|
+
more detail. You can find the commercial license terms in COMM-LICENSE.
|
data/README.md
ADDED
@@ -0,0 +1,195 @@
|
|
1
|
+
# Trailblazer Macro Contract
|
2
|
+
The Contract Macros helps you defining contracts and assists with instantiating and validating data with those contracts at runtime.
|
3
|
+
|
4
|
+
## Table of Contents
|
5
|
+
* [Installation](#installation)
|
6
|
+
* [Contract](#contract)
|
7
|
+
+ [Build](#build)
|
8
|
+
+ [Validation](#validation)
|
9
|
+
- [Key](#key)
|
10
|
+
+ [Persist](#persist)
|
11
|
+
- [Name](#name)
|
12
|
+
+ [Result Object](#result-object)
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
The obvious needs to be in your `Gemfile`.
|
16
|
+
```ruby
|
17
|
+
gem "trailblazer-operation"
|
18
|
+
gem "reform"
|
19
|
+
gem "trailblazer-macro-contract"
|
20
|
+
```
|
21
|
+
Note: you don't need to install anything if you're using the trailblazer gem itself.
|
22
|
+
|
23
|
+
## Contract
|
24
|
+
The Contract Macro, covers the contracts for Trailblazer, they are basically Reform objects that you can define and validate inside an operation. Reform is a fantastic tool for deserializing and validating deeply nested hashes, and then, when valid, writing those to the database using your persistence layer such as ActiveRecord.
|
25
|
+
|
26
|
+
```ruby
|
27
|
+
# app/concepts/song/contract/create.rb
|
28
|
+
module Song::Contract
|
29
|
+
class Create < Reform::Form
|
30
|
+
property :title
|
31
|
+
property :length
|
32
|
+
|
33
|
+
validates :title, length: 2..33
|
34
|
+
validates :length, numericality: true
|
35
|
+
end
|
36
|
+
end
|
37
|
+
```
|
38
|
+
|
39
|
+
The Contract then gets hooked into the operation. using this Macro.
|
40
|
+
```ruby
|
41
|
+
# app/concepts/song/operation/create.rb
|
42
|
+
class Song::Create < Trailblazer::Operation
|
43
|
+
step Model( Song, :new )
|
44
|
+
step Contract::Build( constant: Song::Contract::Create )
|
45
|
+
step Contract::Validate()
|
46
|
+
step Contract::Persist()
|
47
|
+
end
|
48
|
+
```
|
49
|
+
As you can see, using contracts consists of five steps.
|
50
|
+
|
51
|
+
Define the contract class (or multiple of them) for the operation.
|
52
|
+
Plug the contract creation into the operation’s pipe using Contract::Build.
|
53
|
+
Run the contract’s validation for the params using Contract::Validate.
|
54
|
+
If successful, write the sane data to the model(s). This will usually happen in the Contract::Persist macro.
|
55
|
+
After the operation has been run, interpret the result. For instance, a controller calling an operation will render a erroring form for invalid input.
|
56
|
+
|
57
|
+
Here’s what the result would look like after running the Create operation with invalid data.
|
58
|
+
```ruby
|
59
|
+
result = Song::Create.( title: "A" )
|
60
|
+
result.success? #=> false
|
61
|
+
result["contract.default"].errors.messages
|
62
|
+
#=> {:title=>["is too short (minimum is 2 characters)"], :length=>["is not a number"]}
|
63
|
+
```
|
64
|
+
|
65
|
+
### Build
|
66
|
+
The Contract::Build macro helps you to instantiate the contract. It is both helpful for a complete workflow, or to create the contract, only, without validating it, e.g. when presenting the form.
|
67
|
+
```ruby
|
68
|
+
class Song::New < Trailblazer::Operation
|
69
|
+
step Model( Song, :new )
|
70
|
+
step Contract::Build( constant: Song::Contract::Create )
|
71
|
+
end
|
72
|
+
```
|
73
|
+
|
74
|
+
This macro will grab the model from options["model"] and pass it into the contract’s constructor. The contract is then saved in options["contract.default"].
|
75
|
+
```ruby
|
76
|
+
result = Song::New.()
|
77
|
+
result["model"] #=> #<struct Song title=nil, length=nil>
|
78
|
+
result["contract.default"]
|
79
|
+
#=> #<Song::Contract::Create model=#<struct Song title=nil, length=nil>>
|
80
|
+
```
|
81
|
+
The Build macro accepts the :name option to change the name from default.
|
82
|
+
|
83
|
+
### Validation
|
84
|
+
The Contract::Validate macro is responsible for validating the incoming params against its contract. That means you have to use Contract::Build beforehand, or create the contract yourself. The macro will then grab the params and throw then into the contract’s validate (or call) method.
|
85
|
+
|
86
|
+
```ruby
|
87
|
+
class Song::ValidateOnly < Trailblazer::Operation
|
88
|
+
step Model( Song, :new )
|
89
|
+
step Contract::Build( constant: Song::Contract::Create )
|
90
|
+
step Contract::Validate()
|
91
|
+
end
|
92
|
+
```
|
93
|
+
Depending on the outcome of the validation, it either stays on the right track, or deviates to left, skipping the remaining steps.
|
94
|
+
```ruby
|
95
|
+
result = Song::ValidateOnly.({}) # empty params
|
96
|
+
result.success? #=> false
|
97
|
+
```
|
98
|
+
|
99
|
+
Note that Validate really only validates the contract, nothing is written to the model, yet. You need to push data to the model manually, e.g. with Contract::Persist.
|
100
|
+
```ruby
|
101
|
+
result = Song::ValidateOnly.({ title: "Rising Force", length: 13 })
|
102
|
+
|
103
|
+
result.success? #=> true
|
104
|
+
result["model"] #=> #<struct Song title=nil, length=nil>
|
105
|
+
result["contract.default"].title #=> "Rising Force"
|
106
|
+
```
|
107
|
+
|
108
|
+
Validate will use options["params"] as the input. You can change the nesting with the :key option.
|
109
|
+
|
110
|
+
Internally, this macro will simply call Form#validate on the Reform object.
|
111
|
+
|
112
|
+
Note: Reform comes with sophisticated deserialization semantics for nested forms, it might be worth reading a bit about Reform to fully understand what you can do in the Validate step.
|
113
|
+
|
114
|
+
#### Key
|
115
|
+
Per default, Contract::Validate will use options["params"] as the data to be validated. Use the key: option if you want to validate a nested hash from the original params structure.
|
116
|
+
```ruby
|
117
|
+
class Song::Create < Trailblazer::Operation
|
118
|
+
step Model( Song, :new )
|
119
|
+
step Contract::Build( constant: Song::Contract::Create )
|
120
|
+
step Contract::Validate( key: "song" )
|
121
|
+
step Contract::Persist( )
|
122
|
+
end
|
123
|
+
```
|
124
|
+
|
125
|
+
This automatically extracts the nested "song" hash.
|
126
|
+
```ruby
|
127
|
+
result = Song::Create.({ "song" => { title: "Rising Force", length: 13 } })
|
128
|
+
result.success? #=> true
|
129
|
+
```
|
130
|
+
|
131
|
+
If that key isn’t present in the params hash, the operation fails before the actual validation.
|
132
|
+
```ruby
|
133
|
+
result = Song::Create.({ title: "Rising Force", length: 13 })
|
134
|
+
result.success? #=> false
|
135
|
+
```
|
136
|
+
|
137
|
+
Note: String vs. symbol do matter here since the operation will simply do a hash lookup using the key you provided.
|
138
|
+
|
139
|
+
### Persist
|
140
|
+
To push validated data from the contract to the model(s), use Persist. Like Validate, this requires a contract to be set up beforehand.
|
141
|
+
```ruby
|
142
|
+
class Song::Create < Trailblazer::Operation
|
143
|
+
step Model( Song, :new )
|
144
|
+
step Contract::Build( constant: Song::Contract::Create )
|
145
|
+
step Contract::Validate()
|
146
|
+
step Contract::Persist()
|
147
|
+
end
|
148
|
+
```
|
149
|
+
|
150
|
+
After the step, the contract’s attribute values are written to the model, and the contract will call save on the model.
|
151
|
+
```ruby
|
152
|
+
result = Song::Create.( title: "Rising Force", length: 13 )
|
153
|
+
result.success? #=> true
|
154
|
+
result["model"] #=> #<Song title="Rising Force", length=13>
|
155
|
+
```
|
156
|
+
|
157
|
+
You can also configure the Persist step to call sync instead of Reform’s save.
|
158
|
+
```ruby
|
159
|
+
step Persist( method: :sync )
|
160
|
+
```
|
161
|
+
This will only write the contract’s data to the model without calling save on it.
|
162
|
+
|
163
|
+
#### Name
|
164
|
+
Explicit naming for the contract is possible, too.
|
165
|
+
```ruby
|
166
|
+
|
167
|
+
class Song::Create < Trailblazer::Operation
|
168
|
+
step Model( Song, :new )
|
169
|
+
step Contract::Build( name: "form", constant: Song::Contract::Create )
|
170
|
+
step Contract::Validate( name: "form" )
|
171
|
+
step Contract::Persist( name: "form" )
|
172
|
+
end
|
173
|
+
```
|
174
|
+
|
175
|
+
You have to use the name: option to tell each step what contract to use. The contract and its result will now use your name instead of default.
|
176
|
+
```ruby
|
177
|
+
result = Song::Create.({ title: "A" })
|
178
|
+
result["contract.form"].errors.messages #=> {:title=>["is too short (minimum is 2 ch...
|
179
|
+
```
|
180
|
+
|
181
|
+
Use this if your operation has multiple contracts.
|
182
|
+
|
183
|
+
### Result Object
|
184
|
+
The operation will store the validation result for every contract in its own result object.
|
185
|
+
|
186
|
+
The path is result.contract.#{name}.
|
187
|
+
```ruby
|
188
|
+
result = Create.({ length: "A" })
|
189
|
+
|
190
|
+
result["result.contract.default"].success? #=> false
|
191
|
+
result["result.contract.default"].errors #=> Errors object
|
192
|
+
result["result.contract.default"].errors.messages #=> {:length=>["is not a number"]}
|
193
|
+
```
|
194
|
+
|
195
|
+
Each result object responds to success?, failure?, and errors, which is an Errors object. TODO: design/document Errors. WE ARE CURRENTLY WORKING ON A UNIFIED API FOR ERRORS (FOR DRY AND REFORM).
|
data/Rakefile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
2
|
+
require "rake/testtask"
|
3
|
+
require "rubocop/rake_task"
|
4
|
+
|
5
|
+
task :default => %i[test rubocop]
|
6
|
+
|
7
|
+
Rake::TestTask.new(:test) do |test|
|
8
|
+
test.libs << 'test'
|
9
|
+
test.test_files = FileList['test/**/*_test.rb']
|
10
|
+
test.verbose = true
|
11
|
+
end
|
12
|
+
|
13
|
+
RuboCop::RakeTask.new(:rubocop) do |task|
|
14
|
+
task.patterns = ['lib/**/*.rb', 'test/**/*.rb']
|
15
|
+
task.options << "--display-cop-names"
|
16
|
+
task.fail_on_error = false
|
17
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Trailblazer
|
2
|
+
class Operation
|
3
|
+
module Contract
|
4
|
+
def self.Build(name: "default", constant: nil, builder: nil)
|
5
|
+
task = lambda do |(options, flow_options), **circuit_options|
|
6
|
+
result = Build.(options, circuit_options, name: name, constant: constant, builder: builder)
|
7
|
+
|
8
|
+
return Activity::TaskBuilder::Binary.binary_direction_for( result, Activity::Right, Activity::Left ),
|
9
|
+
[options, flow_options]
|
10
|
+
end
|
11
|
+
|
12
|
+
{ task: task, id: "contract.build" }
|
13
|
+
end
|
14
|
+
|
15
|
+
module Build
|
16
|
+
# Build contract at runtime.
|
17
|
+
def self.call(options, circuit_options, name: "default", constant: nil, builder: nil)
|
18
|
+
# TODO: we could probably clean this up a bit at some point.
|
19
|
+
contract_class = constant || options["contract.#{name}.class"] # DISCUSS: Injection possible here?
|
20
|
+
model = options[:model]
|
21
|
+
name = "contract.#{name}"
|
22
|
+
|
23
|
+
options[name] =
|
24
|
+
if builder
|
25
|
+
call_builder( options, circuit_options, builder: builder, constant: contract_class, name: name )
|
26
|
+
else
|
27
|
+
contract_class.new(model)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.call_builder(options, circuit_options, builder:raise, constant:raise, name:raise)
|
32
|
+
tmp_options = options.to_hash.merge(
|
33
|
+
constant: constant,
|
34
|
+
name: name
|
35
|
+
)
|
36
|
+
Trailblazer::Option(builder).( options, tmp_options, circuit_options )
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
module DSL
|
41
|
+
def self.extended(extender)
|
42
|
+
extender.extend(ClassDependencies)
|
43
|
+
warn "[Trailblazer] Using `contract do...end` is deprecated. Please use a form class and the Builder( constant: <Form> ) option."
|
44
|
+
end
|
45
|
+
|
46
|
+
# This is the class level DSL method.
|
47
|
+
# Op.contract #=> returns contract class
|
48
|
+
# Op.contract do .. end # defines contract
|
49
|
+
# Op.contract CommentForm # copies (and subclasses) external contract.
|
50
|
+
# Op.contract CommentForm do .. end # copies and extends contract.
|
51
|
+
def contract(name=:default, constant=nil, base: Reform::Form, &block)
|
52
|
+
heritage.record(:contract, name, constant, &block)
|
53
|
+
|
54
|
+
path, form_class = Trailblazer::DSL::Build.new.({ prefix: :contract, class: base, container: self }, name, constant, block)
|
55
|
+
|
56
|
+
self[path] = form_class
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module Trailblazer
|
2
|
+
class Operation
|
3
|
+
module Contract
|
4
|
+
def self.Persist(method: :save, name: "default")
|
5
|
+
path = "contract.#{name}"
|
6
|
+
step = ->(options, **) { options[path].send(method) }
|
7
|
+
|
8
|
+
task = Activity::TaskBuilder::Binary.( step )
|
9
|
+
|
10
|
+
{ task: task, id: "persist.save" }
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module Trailblazer
|
2
|
+
class Operation
|
3
|
+
module Contract
|
4
|
+
# result.contract = {..}
|
5
|
+
# result.contract.errors = {..}
|
6
|
+
# Deviate to left track if optional key is not found in params.
|
7
|
+
# Deviate to left if validation result falsey.
|
8
|
+
def self.Validate(skip_extract: false, name: "default", representer: false, key: nil) # DISCUSS: should we introduce something like Validate::Deserializer?
|
9
|
+
params_path = "contract.#{name}.params" # extract_params! save extracted params here.
|
10
|
+
|
11
|
+
extract = Validate::Extract.new( key: key, params_path: params_path ).freeze
|
12
|
+
validate = Validate.new( name: name, representer: representer, params_path: params_path ).freeze
|
13
|
+
|
14
|
+
# Build a simple Railway {Activity} for the internal flow.
|
15
|
+
activity = Module.new do
|
16
|
+
extend Activity::Railway(name: "Contract::Validate")
|
17
|
+
|
18
|
+
step extract, id: "#{params_path}_extract" unless skip_extract || representer
|
19
|
+
step validate, id: "contract.#{name}.call"
|
20
|
+
end
|
21
|
+
|
22
|
+
# activity, _ = activity.decompose
|
23
|
+
|
24
|
+
# DISCUSS: use Nested here?
|
25
|
+
{ task: activity, id: "contract.#{name}.validate", outputs: activity.outputs }
|
26
|
+
end
|
27
|
+
|
28
|
+
class Validate
|
29
|
+
# Task: extract the contract's input from params by reading `:key`.
|
30
|
+
class Extract
|
31
|
+
def initialize(key:nil, params_path:nil)
|
32
|
+
@key, @params_path = key, params_path
|
33
|
+
end
|
34
|
+
|
35
|
+
def call( ctx, params:, ** )
|
36
|
+
ctx[@params_path] = @key ? params[@key] : params
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def initialize(name:"default", representer:false, params_path:nil)
|
41
|
+
@name, @representer, @params_path = name, representer, params_path
|
42
|
+
end
|
43
|
+
|
44
|
+
# Task: Validates contract `:name`.
|
45
|
+
def call( ctx, ** )
|
46
|
+
validate!(
|
47
|
+
ctx,
|
48
|
+
representer: ctx["representer.#{@name}.class"] ||= @representer, # FIXME: maybe @representer should use DI.
|
49
|
+
params_path: @params_path
|
50
|
+
)
|
51
|
+
end
|
52
|
+
|
53
|
+
def validate!(options, representer:false, from: :document, params_path:nil)
|
54
|
+
path = "contract.#{@name}"
|
55
|
+
contract = options[path]
|
56
|
+
|
57
|
+
# this is for 1.1-style compatibility and should be removed once we have Deserializer in place:
|
58
|
+
options["result.#{path}"] = result =
|
59
|
+
if representer
|
60
|
+
# use :document as the body and let the representer deserialize to the contract.
|
61
|
+
# this will be simplified once we have Deserializer.
|
62
|
+
# translates to contract.("{document: bla}") { MyRepresenter.new(contract).from_json .. }
|
63
|
+
contract.(options[from]) { |document| representer.new(contract).parse(document) }
|
64
|
+
else
|
65
|
+
# let Reform handle the deserialization.
|
66
|
+
contract.(options[params_path])
|
67
|
+
end
|
68
|
+
|
69
|
+
result.success?
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end # Operation
|
74
|
+
end
|
@@ -0,0 +1,439 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
class DocsContractOverviewTest < Minitest::Spec
|
4
|
+
Song = Struct.new(:length, :title)
|
5
|
+
|
6
|
+
#:overv-reform
|
7
|
+
# app/concepts/song/create.rb
|
8
|
+
class Create < Trailblazer::Operation
|
9
|
+
#~contractonly
|
10
|
+
class MyContract < Reform::Form
|
11
|
+
property :title
|
12
|
+
property :length
|
13
|
+
|
14
|
+
validates :title, presence: true
|
15
|
+
validates :length, numericality: true
|
16
|
+
end
|
17
|
+
#~contractonly end
|
18
|
+
|
19
|
+
step Model( Song, :new )
|
20
|
+
step Contract::Build(constant: MyContract)
|
21
|
+
step Contract::Validate()
|
22
|
+
step Contract::Persist( method: :sync )
|
23
|
+
#~contractonly end
|
24
|
+
end
|
25
|
+
#:overv-reform end
|
26
|
+
|
27
|
+
puts Trailblazer::Operation::Inspect.(Create, style: :rows)
|
28
|
+
|
29
|
+
=begin
|
30
|
+
#:overv-reform-pipe
|
31
|
+
0 ==========================&model.build
|
32
|
+
1 =======================>contract.build
|
33
|
+
2 ==============&validate.params.extract
|
34
|
+
3 ====================&contract.validate
|
35
|
+
4 =========================&persist.save
|
36
|
+
#:overv-reform-pipe end
|
37
|
+
=end
|
38
|
+
|
39
|
+
it do
|
40
|
+
assert Create.(params: {})["contract.default"].must_be_instance_of DocsContractOverviewTest::Create::MyContract
|
41
|
+
end
|
42
|
+
|
43
|
+
#- result
|
44
|
+
it do
|
45
|
+
#:result
|
46
|
+
result = Create.(params: { length: "A" })
|
47
|
+
|
48
|
+
result["result.contract.default"].success? #=> false
|
49
|
+
result["result.contract.default"].errors #=> Errors object
|
50
|
+
result["result.contract.default"].errors.messages #=> {:length=>["is not a number"]}
|
51
|
+
|
52
|
+
#:result end
|
53
|
+
result["result.contract.default"].success?.must_equal false
|
54
|
+
result["result.contract.default"].errors.messages.must_equal ({:title=>["can't be blank"], :length=>["is not a number"]})
|
55
|
+
end
|
56
|
+
|
57
|
+
it "shows 2-level tracing" do
|
58
|
+
result = Create.trace( params: { length: "A" } )
|
59
|
+
result.wtf.gsub(/0x\w+/, "").must_equal %{|-- #<Trailblazer::Activity::Start semantic=:default>
|
60
|
+
|-- model.build
|
61
|
+
|-- contract.build
|
62
|
+
|-- contract.default.validate
|
63
|
+
| |-- #<Trailblazer::Activity::Start semantic=:default>
|
64
|
+
| |-- contract.default.params_extract
|
65
|
+
| |-- contract.default.call
|
66
|
+
| `-- #<Trailblazer::Activity::End semantic=:failure>
|
67
|
+
`-- #<Trailblazer::Operation::Railway::End::Failure semantic=:failure>}
|
68
|
+
end
|
69
|
+
end
|
70
|
+
#---
|
71
|
+
# contract MyContract
|
72
|
+
class DocsContractExplicitTest < Minitest::Spec
|
73
|
+
Song = Struct.new(:length, :title)
|
74
|
+
|
75
|
+
#:reform-inline
|
76
|
+
class MyContract < Reform::Form
|
77
|
+
property :title
|
78
|
+
property :length
|
79
|
+
|
80
|
+
validates :title, presence: true
|
81
|
+
validates :length, numericality: true
|
82
|
+
end
|
83
|
+
#:reform-inline end
|
84
|
+
|
85
|
+
#:reform-inline-op
|
86
|
+
# app/concepts/comment/create.rb
|
87
|
+
class Create < Trailblazer::Operation
|
88
|
+
step Model( Song, :new )
|
89
|
+
step Contract::Build(constant: MyContract)
|
90
|
+
step Contract::Validate()
|
91
|
+
step Contract::Persist( method: :sync )
|
92
|
+
end
|
93
|
+
#:reform-inline-op end
|
94
|
+
end
|
95
|
+
|
96
|
+
#- Validate with manual key extraction
|
97
|
+
class DocsContractSeparateKeyTest < Minitest::Spec
|
98
|
+
Song = Struct.new(:id, :title)
|
99
|
+
#:key-extr
|
100
|
+
class Create < Trailblazer::Operation
|
101
|
+
class MyContract < Reform::Form
|
102
|
+
property :title
|
103
|
+
end
|
104
|
+
|
105
|
+
def type
|
106
|
+
"evergreen" # this is how you could do polymorphic lookups.
|
107
|
+
end
|
108
|
+
|
109
|
+
step Model( Song, :new )
|
110
|
+
step Contract::Build(constant: MyContract)
|
111
|
+
step :extract_params!
|
112
|
+
step Contract::Validate( skip_extract: true )
|
113
|
+
step Contract::Persist( method: :sync )
|
114
|
+
|
115
|
+
def extract_params!(options, **)
|
116
|
+
options["contract.default.params"] = options[:params][type]
|
117
|
+
end
|
118
|
+
end
|
119
|
+
#:key-extr end
|
120
|
+
|
121
|
+
it { Create.(params: { }).inspect(:model).must_equal %{<Result:false [#<struct DocsContractSeparateKeyTest::Song id=nil, title=nil>] >} }
|
122
|
+
it { Create.(params: {"evergreen" => { title: "SVG" }}).inspect(:model).must_equal %{<Result:true [#<struct DocsContractSeparateKeyTest::Song id=nil, title="SVG">] >} }
|
123
|
+
end
|
124
|
+
|
125
|
+
#---
|
126
|
+
#- Contract::Build( constant: XXX )
|
127
|
+
class ContractConstantTest < Minitest::Spec
|
128
|
+
Song = Struct.new(:title, :length) do
|
129
|
+
def save
|
130
|
+
true
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
#:constant-contract
|
135
|
+
# app/concepts/song/contract/create.rb
|
136
|
+
module Song::Contract
|
137
|
+
class Create < Reform::Form
|
138
|
+
property :title
|
139
|
+
property :length
|
140
|
+
|
141
|
+
validates :title, length: 2..33
|
142
|
+
validates :length, numericality: true
|
143
|
+
end
|
144
|
+
end
|
145
|
+
#:constant-contract end
|
146
|
+
|
147
|
+
#:constant
|
148
|
+
class Song::Create < Trailblazer::Operation
|
149
|
+
step Model( Song, :new )
|
150
|
+
step Contract::Build( constant: Song::Contract::Create )
|
151
|
+
step Contract::Validate()
|
152
|
+
step Contract::Persist()
|
153
|
+
end
|
154
|
+
#:constant end
|
155
|
+
|
156
|
+
it { Song::Create.(params: { title: "A" }).inspect(:model).must_equal %{<Result:false [#<struct ContractConstantTest::Song title=nil, length=nil>] >} }
|
157
|
+
it { Song::Create.(params: { title: "Anthony's Song", length: 12 }).inspect(:model).must_equal %{<Result:true [#<struct ContractConstantTest::Song title="Anthony's Song", length=12>] >} }
|
158
|
+
it do
|
159
|
+
#:constant-result
|
160
|
+
result = Song::Create.(params: { title: "A" })
|
161
|
+
result.success? #=> false
|
162
|
+
result["contract.default"].errors.messages
|
163
|
+
#=> {:title=>["is too short (minimum is 2 characters)"], :length=>["is not a number"]}
|
164
|
+
#:constant-result end
|
165
|
+
|
166
|
+
#:constant-result-true
|
167
|
+
result = Song::Create.(params: { title: "Rising Force", length: 13 })
|
168
|
+
result.success? #=> true
|
169
|
+
result["model"] #=> #<Song title="Rising Force", length=13>
|
170
|
+
#:constant-result-true end
|
171
|
+
end
|
172
|
+
|
173
|
+
#---
|
174
|
+
# Song::New
|
175
|
+
#:constant-new
|
176
|
+
class Song::New < Trailblazer::Operation
|
177
|
+
step Model( Song, :new )
|
178
|
+
step Contract::Build( constant: Song::Contract::Create )
|
179
|
+
end
|
180
|
+
#:constant-new end
|
181
|
+
|
182
|
+
it { Song::New.(params: {}).inspect(:model).must_equal %{<Result:true [#<struct ContractConstantTest::Song title=nil, length=nil>] >} }
|
183
|
+
it { Song::New.(params: {})["contract.default"].model.inspect.must_equal %{#<struct ContractConstantTest::Song title=nil, length=nil>} }
|
184
|
+
it do
|
185
|
+
#:constant-new-result
|
186
|
+
result = Song::New.(params: {})
|
187
|
+
result["model"] #=> #<struct Song title=nil, length=nil>
|
188
|
+
result["contract.default"]
|
189
|
+
#=> #<Song::Contract::Create model=#<struct Song title=nil, length=nil>>
|
190
|
+
#:constant-new-result end
|
191
|
+
end
|
192
|
+
|
193
|
+
#---
|
194
|
+
#:validate-only
|
195
|
+
class Song::ValidateOnly < Trailblazer::Operation
|
196
|
+
step Model( Song, :new )
|
197
|
+
step Contract::Build( constant: Song::Contract::Create )
|
198
|
+
step Contract::Validate()
|
199
|
+
end
|
200
|
+
#:validate-only end
|
201
|
+
|
202
|
+
it { Song::ValidateOnly.(params: {}).inspect(:model).must_equal %{<Result:false [#<struct ContractConstantTest::Song title=nil, length=nil>] >} }
|
203
|
+
it do
|
204
|
+
result = Song::ValidateOnly.(params: { title: "Rising Forse", length: 13 })
|
205
|
+
result.inspect(:model).must_equal %{<Result:true [#<struct ContractConstantTest::Song title=nil, length=nil>] >}
|
206
|
+
end
|
207
|
+
|
208
|
+
it do
|
209
|
+
#:validate-only-result-false
|
210
|
+
result = Song::ValidateOnly.(params: {}) # empty params
|
211
|
+
result.success? #=> false
|
212
|
+
#:validate-only-result-false end
|
213
|
+
end
|
214
|
+
|
215
|
+
it do
|
216
|
+
#:validate-only-result
|
217
|
+
result = Song::ValidateOnly.(params: { title: "Rising Force", length: 13 })
|
218
|
+
|
219
|
+
result.success? #=> true
|
220
|
+
result["model"] #=> #<struct Song title=nil, length=nil>
|
221
|
+
result["contract.default"].title #=> "Rising Force"
|
222
|
+
#:validate-only-result end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
#---
|
227
|
+
#- Validate( key: :song )
|
228
|
+
class DocsContractKeyTest < Minitest::Spec
|
229
|
+
Song = Class.new(ContractConstantTest::Song)
|
230
|
+
|
231
|
+
module Song::Contract
|
232
|
+
Create = ContractConstantTest::Song::Contract::Create
|
233
|
+
end
|
234
|
+
|
235
|
+
#:key
|
236
|
+
class Song::Create < Trailblazer::Operation
|
237
|
+
step Model( Song, :new )
|
238
|
+
step Contract::Build( constant: Song::Contract::Create )
|
239
|
+
step Contract::Validate( key: "song" )
|
240
|
+
step Contract::Persist( )
|
241
|
+
end
|
242
|
+
#:key end
|
243
|
+
|
244
|
+
it { Song::Create.(params: {}).inspect(:model, "result.contract.default.extract").must_equal %{<Result:false [#<struct DocsContractKeyTest::Song title=nil, length=nil>, nil] >} }
|
245
|
+
it { Song::Create.(params: {"song" => { title: "SVG", length: 13 }}).inspect(:model).must_equal %{<Result:true [#<struct DocsContractKeyTest::Song title=\"SVG\", length=13>] >} }
|
246
|
+
it do
|
247
|
+
#:key-res
|
248
|
+
result = Song::Create.(params: { "song" => { title: "Rising Force", length: 13 } })
|
249
|
+
result.success? #=> true
|
250
|
+
#:key-res end
|
251
|
+
|
252
|
+
#:key-res-false
|
253
|
+
result = Song::Create.(params: { title: "Rising Force", length: 13 })
|
254
|
+
result.success? #=> false
|
255
|
+
#:key-res-false end
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
#- Contract::Build[ constant: XXX, name: AAA ]
|
260
|
+
class ContractNamedConstantTest < Minitest::Spec
|
261
|
+
Song = Class.new(ContractConstantTest::Song)
|
262
|
+
|
263
|
+
module Song::Contract
|
264
|
+
Create = ContractConstantTest::Song::Contract::Create
|
265
|
+
end
|
266
|
+
|
267
|
+
#:constant-name
|
268
|
+
class Song::Create < Trailblazer::Operation
|
269
|
+
step Model( Song, :new )
|
270
|
+
step Contract::Build( name: "form", constant: Song::Contract::Create )
|
271
|
+
step Contract::Validate( name: "form" )
|
272
|
+
step Contract::Persist( name: "form" )
|
273
|
+
end
|
274
|
+
#:constant-name end
|
275
|
+
|
276
|
+
it { Song::Create.(params: { title: "A" }).inspect(:model).must_equal %{<Result:false [#<struct ContractNamedConstantTest::Song title=nil, length=nil>] >} }
|
277
|
+
it { Song::Create.(params: { title: "Anthony's Song", length: 13 }).inspect(:model).must_equal %{<Result:true [#<struct ContractNamedConstantTest::Song title="Anthony's Song", length=13>] >} }
|
278
|
+
|
279
|
+
it do
|
280
|
+
#:name-res
|
281
|
+
result = Song::Create.(params: { title: "A" })
|
282
|
+
result["contract.form"].errors.messages #=> {:title=>["is too short (minimum is 2 ch...
|
283
|
+
#:name-res end
|
284
|
+
end
|
285
|
+
end
|
286
|
+
|
287
|
+
#---
|
288
|
+
#- dependency injection
|
289
|
+
#- contract class
|
290
|
+
class ContractInjectConstantTest < Minitest::Spec
|
291
|
+
Song = Struct.new(:id, :title)
|
292
|
+
#:di-constant-contract
|
293
|
+
class MyContract < Reform::Form
|
294
|
+
property :title
|
295
|
+
validates :title, length: 2..33
|
296
|
+
end
|
297
|
+
#:di-constant-contract end
|
298
|
+
#:di-constant
|
299
|
+
class Create < Trailblazer::Operation
|
300
|
+
step Model( Song, :new )
|
301
|
+
step Contract::Build()
|
302
|
+
step Contract::Validate()
|
303
|
+
step Contract::Persist( method: :sync )
|
304
|
+
end
|
305
|
+
#:di-constant end
|
306
|
+
|
307
|
+
it do
|
308
|
+
#:di-contract-call
|
309
|
+
Create.(
|
310
|
+
params: { title: "Anthony's Song" },
|
311
|
+
"contract.default.class" => MyContract
|
312
|
+
)
|
313
|
+
#:di-contract-call end
|
314
|
+
end
|
315
|
+
it { Create.(params: { title: "A" }, "contract.default.class" => MyContract).inspect(:model).must_equal %{<Result:false [#<struct ContractInjectConstantTest::Song id=nil, title=nil>] >} }
|
316
|
+
it { Create.(params: { title: "Anthony's Song" }, "contract.default.class" => MyContract).inspect(:model).must_equal %{<Result:true [#<struct ContractInjectConstantTest::Song id=nil, title="Anthony's Song">] >} }
|
317
|
+
end
|
318
|
+
|
319
|
+
class DryValidationContractTest < Minitest::Spec
|
320
|
+
Song = Struct.new(:id, :title)
|
321
|
+
#---
|
322
|
+
# DRY-validation with multiple validation sets,
|
323
|
+
#- result.path
|
324
|
+
#:dry-schema
|
325
|
+
require "reform/form/dry"
|
326
|
+
class Create < Trailblazer::Operation
|
327
|
+
# contract to verify params formally.
|
328
|
+
class MyContract < Reform::Form
|
329
|
+
feature Reform::Form::Dry
|
330
|
+
property :id
|
331
|
+
property :title
|
332
|
+
|
333
|
+
validation name: :default do
|
334
|
+
required(:id).filled
|
335
|
+
end
|
336
|
+
|
337
|
+
validation name: :extra, if: :default do
|
338
|
+
required(:title).filled(min_size?: 2)
|
339
|
+
end
|
340
|
+
end
|
341
|
+
#~form end
|
342
|
+
|
343
|
+
step Model( Song, :new ) # create the op's main model.
|
344
|
+
step Contract::Build( constant: MyContract ) # create the Reform contract.
|
345
|
+
step Contract::Validate() # validate the Reform contract.
|
346
|
+
step Contract::Persist( method: :sync) # persist the contract's data via the model.
|
347
|
+
#~form end
|
348
|
+
end
|
349
|
+
#:dry-schema end
|
350
|
+
|
351
|
+
puts "@@@@@ #{Trailblazer::Operation::Inspect.(Create, style: :rows)}"
|
352
|
+
|
353
|
+
it { Create.(params: {}).inspect("result.contract.default").must_include "Result:false"}
|
354
|
+
it { Create.(params: {}).inspect("result.contract.default").must_include "@errors={:id=>[\"must be filled\""}
|
355
|
+
|
356
|
+
it { Create.(params: { id: 1 }).inspect(:model, "result.contract.default").must_include "Result:false"}
|
357
|
+
it { Create.(params: { id: 1 }).inspect(:model, "result.contract.default").must_include "@errors={:title=>[\"must be filled\", \"size cannot be less than 2\"]}"}
|
358
|
+
it { Create.(params: { id: 1 }).inspect(:model, "result.contract.default").wont_include ":id=>[\"must be filled\""}
|
359
|
+
|
360
|
+
it { Create.(params: { id: 1, title: "" }).inspect(:model).must_equal %{<Result:false [#<struct DryValidationContractTest::Song id=nil, title=nil>] >} }
|
361
|
+
it { Create.(params: { id: 1, title: "Y" }).inspect(:model).must_equal %{<Result:false [#<struct DryValidationContractTest::Song id=nil, title=nil>] >} }
|
362
|
+
it { Create.(params: { id: 1, title: "Yo" }).inspect(:model).must_equal %{<Result:true [#<struct DryValidationContractTest::Song id=1, title="Yo">] >} }
|
363
|
+
end
|
364
|
+
|
365
|
+
class DocContractBuilderTest < Minitest::Spec
|
366
|
+
Song = Struct.new(:id, :title)
|
367
|
+
#---
|
368
|
+
#- builder:
|
369
|
+
#:builder-option
|
370
|
+
class Create < Trailblazer::Operation
|
371
|
+
|
372
|
+
class MyContract < Reform::Form
|
373
|
+
property :title
|
374
|
+
property :current_user, virtual: true
|
375
|
+
|
376
|
+
validate :current_user?
|
377
|
+
validates :title, presence: true
|
378
|
+
|
379
|
+
def current_user?
|
380
|
+
return true if defined?(current_user)
|
381
|
+
false
|
382
|
+
end
|
383
|
+
end
|
384
|
+
|
385
|
+
step Model( Song, :new )
|
386
|
+
step Contract::Build( constant: MyContract, builder: :default_contract! )
|
387
|
+
step Contract::Validate()
|
388
|
+
step Contract::Persist( method: :sync )
|
389
|
+
|
390
|
+
def default_contract!(options, constant:, model:, **)
|
391
|
+
constant.new(model, current_user: options [:current_user])
|
392
|
+
end
|
393
|
+
end
|
394
|
+
#:builder-option end
|
395
|
+
|
396
|
+
it { Create.(params: {}).inspect(:model).must_equal %{<Result:false [#<struct DocContractBuilderTest::Song id=nil, title=nil>] >} }
|
397
|
+
it { Create.(params: { title: "title"}, current_user: Module).inspect(:model).must_equal %{<Result:true [#<struct DocContractBuilderTest::Song id=nil, title="title">] >} }
|
398
|
+
end
|
399
|
+
|
400
|
+
class DocContractTest < Minitest::Spec
|
401
|
+
Song = Struct.new(:id, :title)
|
402
|
+
#---
|
403
|
+
# with contract block, and inheritance, the old way.
|
404
|
+
class Block < Trailblazer::Operation
|
405
|
+
class MyContract < Reform::Form
|
406
|
+
property :title
|
407
|
+
end
|
408
|
+
|
409
|
+
step Model( Song, :new )
|
410
|
+
step Contract::Build(constant: MyContract) # resolves to "contract.class.default" and is resolved at runtime.
|
411
|
+
step Contract::Validate()
|
412
|
+
step Contract::Persist( method: :sync )
|
413
|
+
end
|
414
|
+
|
415
|
+
it { Block.(params: {}).inspect(:model).must_equal %{<Result:true [#<struct DocContractTest::Song id=nil, title=nil>] >} }
|
416
|
+
it { Block.(params: { id:1, title: "Fame" }).inspect(:model).must_equal %{<Result:true [#<struct DocContractTest::Song id=nil, title="Fame">] >} }
|
417
|
+
|
418
|
+
class Breach < Block
|
419
|
+
class MyContract < MyContract
|
420
|
+
property :id
|
421
|
+
end
|
422
|
+
|
423
|
+
step Contract::Build(constant: MyContract), replace: "contract.build"
|
424
|
+
end
|
425
|
+
|
426
|
+
it { Breach.(params: { id:1, title: "Fame" }).inspect(:model).must_equal %{<Result:true [#<struct DocContractTest::Song id=1, title="Fame">] >} }
|
427
|
+
|
428
|
+
#-
|
429
|
+
# with constant.
|
430
|
+
class Break < Block
|
431
|
+
class MyContract < Reform::Form
|
432
|
+
property :id
|
433
|
+
end
|
434
|
+
# override the original block as if it's never been there.
|
435
|
+
step Contract::Build(constant: MyContract), replace: "contract.build"
|
436
|
+
end
|
437
|
+
|
438
|
+
it { Break.(params: { id:1, title: "Fame" }).inspect(:model).must_equal %{<Result:true [#<struct DocContractTest::Song id=1, title=nil>] >} }
|
439
|
+
end
|