pragma 1.2.6 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +11 -8
- data/Gemfile +5 -0
- data/README.md +65 -10
- data/lib/pragma.rb +12 -1
- data/lib/pragma/decorator/error.rb +11 -0
- data/lib/pragma/operation/create.rb +14 -23
- data/lib/pragma/operation/destroy.rb +17 -15
- data/lib/pragma/operation/index.rb +19 -101
- data/lib/pragma/operation/macro/classes.rb +96 -0
- data/lib/pragma/operation/macro/contract/build.rb +27 -0
- data/lib/pragma/operation/macro/contract/persist.rb +27 -0
- data/lib/pragma/operation/macro/contract/validate.rb +27 -0
- data/lib/pragma/operation/macro/decorator.rb +61 -0
- data/lib/pragma/operation/macro/model.rb +27 -0
- data/lib/pragma/operation/macro/pagination.rb +92 -0
- data/lib/pragma/operation/macro/policy.rb +40 -0
- data/lib/pragma/operation/show.rb +8 -15
- data/lib/pragma/operation/update.rb +12 -23
- data/lib/pragma/version.rb +2 -1
- data/pragma.gemspec +7 -5
- metadata +34 -14
- data/doc/01-sensible-defaults.md +0 -50
- data/doc/02-crud-operations.md +0 -109
- data/lib/pragma/operation/defaults.rb +0 -83
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pragma
|
4
|
+
module Operation
|
5
|
+
module Macro
|
6
|
+
def self.Classes
|
7
|
+
step = ->(input, options) { Classes.for(input, options) }
|
8
|
+
[step, name: 'classes']
|
9
|
+
end
|
10
|
+
|
11
|
+
module Classes
|
12
|
+
class << self
|
13
|
+
def for(input, options)
|
14
|
+
{
|
15
|
+
'model.class' => expected_model_class(input, options),
|
16
|
+
'policy.default.class' => expected_policy_class(input, options),
|
17
|
+
'policy.default.scope.class' => expected_policy_scope_class(input, options),
|
18
|
+
'decorator.instance.class' => expected_instance_decorator_class(input, options),
|
19
|
+
'decorator.collection.class' => expected_collection_decorator_class(input, options),
|
20
|
+
'contract.default.class' => expected_contract_class(input, options)
|
21
|
+
}.each_pair do |key, value|
|
22
|
+
next if options[key]
|
23
|
+
|
24
|
+
# FIXME: This entire block is required to trigger Rails autoloading. Ugh.
|
25
|
+
begin
|
26
|
+
Object.const_get(value)
|
27
|
+
rescue NameError => e
|
28
|
+
# We check the error message to avoid silently ignoring other NameErrors
|
29
|
+
# thrown while initializing the constant.
|
30
|
+
if e.message.start_with?('uninitialized constant')
|
31
|
+
# Required instead of a simple equality check because loading
|
32
|
+
# API::V1::Post::Contract::Index might throw "uninitialized constant
|
33
|
+
# API::V1::Post::Contract" if the resource has no contracts at all.
|
34
|
+
error_constant = e.message.split.last
|
35
|
+
raise e unless value.sub(/\A::/, '').start_with?(error_constant)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
options[key] = if Object.const_defined?(value)
|
40
|
+
Object.const_get(value)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def resource_namespace(input, _options)
|
48
|
+
input.class.name.split('::')[0..-3]
|
49
|
+
end
|
50
|
+
|
51
|
+
def expected_model_class(input, options)
|
52
|
+
[
|
53
|
+
nil,
|
54
|
+
resource_namespace(input, options).last
|
55
|
+
].join('::')
|
56
|
+
end
|
57
|
+
|
58
|
+
def expected_policy_class(input, options)
|
59
|
+
[
|
60
|
+
resource_namespace(input, options),
|
61
|
+
'Policy'
|
62
|
+
].join('::')
|
63
|
+
end
|
64
|
+
|
65
|
+
def expected_policy_scope_class(input, options)
|
66
|
+
"#{expected_policy_class(input, options)}::Scope"
|
67
|
+
end
|
68
|
+
|
69
|
+
def expected_instance_decorator_class(input, options)
|
70
|
+
[
|
71
|
+
resource_namespace(input, options),
|
72
|
+
'Decorator',
|
73
|
+
'Instance'
|
74
|
+
].join('::')
|
75
|
+
end
|
76
|
+
|
77
|
+
def expected_collection_decorator_class(input, options)
|
78
|
+
[
|
79
|
+
resource_namespace(input, options),
|
80
|
+
'Decorator',
|
81
|
+
'Collection'
|
82
|
+
].join('::')
|
83
|
+
end
|
84
|
+
|
85
|
+
def expected_contract_class(input, options)
|
86
|
+
[
|
87
|
+
resource_namespace(input, options),
|
88
|
+
'Contract',
|
89
|
+
input.class.name.split('::').last
|
90
|
+
].join('::')
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'trailblazer/operation/contract'
|
4
|
+
|
5
|
+
module Pragma
|
6
|
+
module Operation
|
7
|
+
module Macro
|
8
|
+
module Contract
|
9
|
+
def self.Build(name: 'default', constant: nil, builder: nil)
|
10
|
+
step = lambda do |input, options|
|
11
|
+
Trailblazer::Operation::Contract::Build.for(
|
12
|
+
input,
|
13
|
+
options,
|
14
|
+
name: name,
|
15
|
+
constant: constant,
|
16
|
+
builder: builder
|
17
|
+
).tap do |contract|
|
18
|
+
contract.current_user = options['current_user']
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
[step, name: 'contract.build']
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'trailblazer/operation/persist'
|
4
|
+
|
5
|
+
module Pragma
|
6
|
+
module Operation
|
7
|
+
module Macro
|
8
|
+
module Contract
|
9
|
+
def self.Persist(**args)
|
10
|
+
step = lambda do |input, options|
|
11
|
+
Trailblazer::Operation::Pipetree::Step.new(
|
12
|
+
Trailblazer::Operation::Contract::Persist(**args).first
|
13
|
+
).call(input, options).tap do |result|
|
14
|
+
unless result
|
15
|
+
options['result.response'] = Pragma::Operation::Response::UnprocessableEntity.new(
|
16
|
+
errors: options['model'].errors.messages
|
17
|
+
).decorate_with(Pragma::Decorator::Error)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
[step, name: 'persist.save']
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'trailblazer/operation/validate'
|
4
|
+
|
5
|
+
module Pragma
|
6
|
+
module Operation
|
7
|
+
module Macro
|
8
|
+
module Contract
|
9
|
+
def self.Validate(name: 'default', **args)
|
10
|
+
step = lambda do |input, options|
|
11
|
+
Trailblazer::Operation::Pipetree::Step.new(
|
12
|
+
Trailblazer::Operation::Contract::Validate(**args).first
|
13
|
+
).call(input, options).tap do |result|
|
14
|
+
unless result
|
15
|
+
options['result.response'] = Pragma::Operation::Response::UnprocessableEntity.new(
|
16
|
+
errors: options['contract.default'].errors.messages
|
17
|
+
).decorate_with(Pragma::Decorator::Error)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
[step, name: "contract.#{name}.validate"]
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pragma
|
4
|
+
module Operation
|
5
|
+
module Macro
|
6
|
+
def self.Decorator(name: :instance)
|
7
|
+
step = ->(input, options) { Decorator.for(input, name, options) }
|
8
|
+
[step, name: "decorator.#{name}"]
|
9
|
+
end
|
10
|
+
|
11
|
+
module Decorator
|
12
|
+
class << self
|
13
|
+
def for(_input, name, options)
|
14
|
+
unless validate_params(options)
|
15
|
+
handle_invalid_contract(options)
|
16
|
+
return false
|
17
|
+
end
|
18
|
+
|
19
|
+
options["result.decorator.#{name}"] = options["decorator.#{name}.class"].new(
|
20
|
+
options['model']
|
21
|
+
)
|
22
|
+
|
23
|
+
validate_expansion(options, name)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def validate_params(options)
|
29
|
+
options['contract.expand'] = Dry::Validation.Schema do
|
30
|
+
optional(:expand).each(:str?)
|
31
|
+
end
|
32
|
+
|
33
|
+
options['result.contract.expand'] = options['contract.expand'].call(options['params'])
|
34
|
+
|
35
|
+
options['result.contract.expand'].errors.empty?
|
36
|
+
end
|
37
|
+
|
38
|
+
def handle_invalid_contract(options)
|
39
|
+
options['result.response'] = Response::UnprocessableEntity.new(
|
40
|
+
errors: options['result.contract.expand'].errors
|
41
|
+
).decorate_with(Pragma::Decorator::Error)
|
42
|
+
end
|
43
|
+
|
44
|
+
def validate_expansion(options, name)
|
45
|
+
return true unless options["result.decorator.#{name}"].respond_to?(:validate_expansion)
|
46
|
+
options["result.decorator.#{name}"].validate_expansion(options['params'][:expand])
|
47
|
+
true
|
48
|
+
rescue Pragma::Decorator::Association::ExpansionError => e
|
49
|
+
options['result.response'] = Response::BadRequest.new(
|
50
|
+
entity: Pragma::Operation::Error.new(
|
51
|
+
error_type: :expansion_error,
|
52
|
+
error_message: e.message
|
53
|
+
)
|
54
|
+
).decorate_with(Pragma::Decorator::Error)
|
55
|
+
false
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'trailblazer/operation/model'
|
4
|
+
|
5
|
+
module Pragma
|
6
|
+
module Operation
|
7
|
+
module Macro
|
8
|
+
def self.Model(action = nil)
|
9
|
+
step = lambda do |input, options|
|
10
|
+
Trailblazer::Operation::Pipetree::Step.new(
|
11
|
+
Trailblazer::Operation::Model.for(options['model.class'], action),
|
12
|
+
'model.class' => options['model.class'],
|
13
|
+
'model.action' => action
|
14
|
+
).call(input, options).tap do |result|
|
15
|
+
unless result
|
16
|
+
options['result.response'] = Pragma::Operation::Response::NotFound.new.decorate_with(
|
17
|
+
Pragma::Decorator::Error
|
18
|
+
)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
[step, name: 'model.build']
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Pragma
|
4
|
+
module Operation
|
5
|
+
module Macro
|
6
|
+
def self.Pagination
|
7
|
+
step = ->(input, options) { Pagination.for(input, options) }
|
8
|
+
[step, name: 'pagination']
|
9
|
+
end
|
10
|
+
|
11
|
+
module Pagination
|
12
|
+
class << self
|
13
|
+
def for(_input, options)
|
14
|
+
set_defaults(options)
|
15
|
+
normalize_params(options)
|
16
|
+
|
17
|
+
unless validate_params(options)
|
18
|
+
handle_invalid_contract(options)
|
19
|
+
return false
|
20
|
+
end
|
21
|
+
|
22
|
+
options['model'] = options['model'].paginate(
|
23
|
+
page: page(options, **options),
|
24
|
+
per_page: per_page(options, **options)
|
25
|
+
)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def set_defaults(options)
|
31
|
+
{
|
32
|
+
'pagination.page_param' => :page,
|
33
|
+
'pagination.per_page_param' => :per_page,
|
34
|
+
'pagination.default_per_page' => 30,
|
35
|
+
'pagination.max_per_page' => 100
|
36
|
+
}.each_pair do |key, value|
|
37
|
+
options[key] ||= value
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def normalize_params(options)
|
42
|
+
# This is required because Rails treats all incoming parameters as strings, since it
|
43
|
+
# can't distinguish. Maybe there's a better way to do it?
|
44
|
+
options['params'].tap do |p|
|
45
|
+
%w[pagination.page_param pagination.per_page_param].each do |key|
|
46
|
+
if p[options[key]] && p[options[key]].respond_to?(:to_i)
|
47
|
+
p[options[key]] = p[options[key]].to_i
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def validate_params(options)
|
54
|
+
options['contract.pagination'] = Dry::Validation.Schema do
|
55
|
+
optional(options['pagination.page_param']).filled { int? & gteq?(1) }
|
56
|
+
optional(options['pagination.per_page_param']).filled { int? & (gteq?(1) & lteq?(options['pagination.max_per_page'])) }
|
57
|
+
end
|
58
|
+
|
59
|
+
options['result.contract.pagination'] = options['contract.pagination'].call(options['params'])
|
60
|
+
|
61
|
+
options['result.contract.pagination'].errors.empty?
|
62
|
+
end
|
63
|
+
|
64
|
+
def handle_invalid_contract(options)
|
65
|
+
options['result.response'] = Response::UnprocessableEntity.new(
|
66
|
+
errors: options['result.contract.pagination'].errors
|
67
|
+
).decorate_with(Pragma::Decorator::Error)
|
68
|
+
end
|
69
|
+
|
70
|
+
def page(options, params:, **)
|
71
|
+
return 1 if
|
72
|
+
!params[options['pagination.page_param']] ||
|
73
|
+
params[options['pagination.page_param']].blank?
|
74
|
+
|
75
|
+
params[options['pagination.page_param']].to_i
|
76
|
+
end
|
77
|
+
|
78
|
+
def per_page(options, params:, **)
|
79
|
+
return options['pagination.default_per_page'] if
|
80
|
+
!params[options['pagination.per_page_param']] ||
|
81
|
+
params[options['pagination.per_page_param']].blank?
|
82
|
+
|
83
|
+
[
|
84
|
+
params[options['pagination.per_page_param']].to_i,
|
85
|
+
options['pagination.max_per_page']
|
86
|
+
].min
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'trailblazer/operation/pundit'
|
4
|
+
|
5
|
+
module Pragma
|
6
|
+
module Operation
|
7
|
+
module Macro
|
8
|
+
def self.Policy(name: :default)
|
9
|
+
step = ->(input, options) { Policy.for(input, name, options) }
|
10
|
+
[step, name: "policy.#{name}"]
|
11
|
+
end
|
12
|
+
|
13
|
+
module Policy
|
14
|
+
class << self
|
15
|
+
def for(input, name, options)
|
16
|
+
policy = options["policy.#{name}.class"].new(options['current_user'], options['model'])
|
17
|
+
|
18
|
+
options["result.policy.#{name}"] = Trailblazer::Operation::Result.new(
|
19
|
+
policy.send("#{input.class.operation_name}?"),
|
20
|
+
'policy' => policy
|
21
|
+
)
|
22
|
+
|
23
|
+
unless options["result.policy.#{name}"].success?
|
24
|
+
handle_unauthorized!(options)
|
25
|
+
return false
|
26
|
+
end
|
27
|
+
|
28
|
+
true
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def handle_unauthorized!(options)
|
34
|
+
options['result.response'] = Pragma::Operation::Response::Forbidden.new.decorate_with(Pragma::Decorator::Error)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -1,26 +1,19 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
2
3
|
module Pragma
|
3
4
|
module Operation
|
4
5
|
# Finds the requested record, authorizes it and decorates it.
|
5
6
|
#
|
6
7
|
# @author Alessandro Desantis
|
7
8
|
class Show < Pragma::Operation::Base
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
respond_with resource: decorate(context.record), status: :ok
|
15
|
-
end
|
16
|
-
|
17
|
-
protected
|
9
|
+
step Macro::Classes()
|
10
|
+
step Macro::Model(:find_by), fail_fast: true
|
11
|
+
step Macro::Policy(), fail_fast: true
|
12
|
+
step Macro::Decorator()
|
13
|
+
step :respond!
|
18
14
|
|
19
|
-
|
20
|
-
|
21
|
-
# @return [Object]
|
22
|
-
def find_record
|
23
|
-
self.class.model_klass.find(params[:id])
|
15
|
+
def respond!(options)
|
16
|
+
options['result.response'] = Response::Ok.new(entity: options['result.decorator.instance'])
|
24
17
|
end
|
25
18
|
end
|
26
19
|
end
|
@@ -1,33 +1,22 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
2
3
|
module Pragma
|
3
4
|
module Operation
|
4
|
-
# Finds
|
5
|
-
# responds with the decorated record.
|
5
|
+
# Finds an existing record, updates it and responds with the decorated record.
|
6
6
|
#
|
7
7
|
# @author Alessandro Desantis
|
8
8
|
class Update < Pragma::Operation::Base
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
context.contract.save
|
19
|
-
context.record.save!
|
20
|
-
|
21
|
-
respond_with resource: decorate(context.record), status: :ok
|
22
|
-
end
|
23
|
-
|
24
|
-
protected
|
9
|
+
step Macro::Classes()
|
10
|
+
step Macro::Model(:find_by), fail_fast: true
|
11
|
+
step Macro::Policy(), fail_fast: true
|
12
|
+
step Macro::Contract::Build()
|
13
|
+
step Macro::Contract::Validate(), fail_fast: true
|
14
|
+
step Macro::Contract::Persist(), fail_fast: true
|
15
|
+
step Macro::Decorator()
|
16
|
+
step :respond!
|
25
17
|
|
26
|
-
|
27
|
-
|
28
|
-
# @return [Object]
|
29
|
-
def find_record
|
30
|
-
self.class.model_klass.find(params[:id])
|
18
|
+
def respond!(options)
|
19
|
+
options['result.response'] = Response::Ok.new(entity: options['result.decorator.instance'])
|
31
20
|
end
|
32
21
|
end
|
33
22
|
end
|