pragma 1.2.6 → 2.0.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.
- 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
|