shamu 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.codeclimate.yml +26 -0
- data/.gitignore +2 -1
- data/.rubocop.yml +89 -30
- data/.yardopts +4 -5
- data/Gemfile +24 -12
- data/Guardfile +5 -0
- data/LABELS.md +22 -0
- data/README.md +41 -0
- data/Rakefile +12 -0
- data/circle.yml +7 -3
- data/config.ru +7 -0
- data/lib/shamu/active_record.rb +7 -0
- data/lib/shamu/attributes/assignment.rb +114 -0
- data/lib/shamu/attributes/equality.rb +40 -0
- data/lib/shamu/attributes/fluid_assignment.rb +49 -0
- data/lib/shamu/attributes/validation.rb +74 -0
- data/lib/shamu/attributes.rb +255 -0
- data/lib/shamu/auditing/README.md +0 -0
- data/lib/shamu/auditing/audit_record.rb +32 -0
- data/lib/shamu/auditing/auditing_service.rb +32 -0
- data/lib/shamu/auditing/list_scope.rb +22 -0
- data/lib/shamu/auditing/logging_auditing_service.rb +16 -0
- data/lib/shamu/auditing/support.rb +75 -0
- data/lib/shamu/auditing/transaction.rb +58 -0
- data/lib/shamu/auditing.rb +12 -0
- data/lib/shamu/entities/README.md +1 -0
- data/lib/shamu/entities/active_record.rb +123 -0
- data/lib/shamu/entities/active_record_soft_destroy.rb +91 -0
- data/lib/shamu/entities/entity.rb +196 -0
- data/lib/shamu/entities/entity_path.rb +87 -0
- data/lib/shamu/entities/identity_cache.rb +64 -0
- data/lib/shamu/entities/list.rb +54 -0
- data/lib/shamu/entities/list_scope/dates.rb +57 -0
- data/lib/shamu/entities/list_scope/paging.rb +51 -0
- data/lib/shamu/entities/list_scope/scoped_paging.rb +65 -0
- data/lib/shamu/entities/list_scope/sorting.rb +76 -0
- data/lib/shamu/entities/list_scope.rb +105 -0
- data/lib/shamu/entities/null_entity.rb +88 -0
- data/lib/shamu/entities.rb +11 -0
- data/lib/shamu/error.rb +23 -5
- data/lib/shamu/events/README.md +0 -0
- data/lib/shamu/events/active_record/channel.rb +36 -0
- data/lib/shamu/events/active_record/message.rb +52 -0
- data/lib/shamu/events/active_record/migration.rb +49 -0
- data/lib/shamu/events/active_record/runner.rb +28 -0
- data/lib/shamu/events/active_record/service.rb +174 -0
- data/lib/shamu/events/active_record.rb +13 -0
- data/lib/shamu/events/channel_stats.rb +23 -0
- data/lib/shamu/events/error.rb +24 -0
- data/lib/shamu/events/events_service.rb +136 -0
- data/lib/shamu/events/in_memory/async_service.rb +48 -0
- data/lib/shamu/events/in_memory/service.rb +97 -0
- data/lib/shamu/events/in_memory.rb +10 -0
- data/lib/shamu/events/message.rb +38 -0
- data/lib/shamu/events/support.rb +60 -0
- data/lib/shamu/events.rb +12 -0
- data/lib/shamu/features/README.md +0 -0
- data/lib/shamu/features/conditions/condition.rb +39 -0
- data/lib/shamu/features/conditions/env.rb +37 -0
- data/lib/shamu/features/conditions/hosts.rb +25 -0
- data/lib/shamu/features/conditions/matching.rb +16 -0
- data/lib/shamu/features/conditions/not_matching.rb +16 -0
- data/lib/shamu/features/conditions/percentage.rb +44 -0
- data/lib/shamu/features/conditions/proc.rb +54 -0
- data/lib/shamu/features/conditions/roles.rb +23 -0
- data/lib/shamu/features/conditions/schedule_at.rb +27 -0
- data/lib/shamu/features/conditions.rb +18 -0
- data/lib/shamu/features/config_service.rb +10 -0
- data/lib/shamu/features/context.rb +80 -0
- data/lib/shamu/features/env_store.rb +88 -0
- data/lib/shamu/features/errors.rb +29 -0
- data/lib/shamu/features/features_service.rb +168 -0
- data/lib/shamu/features/list_scope.rb +30 -0
- data/lib/shamu/features/selector.rb +50 -0
- data/lib/shamu/features/support.rb +51 -0
- data/lib/shamu/features/toggle.rb +149 -0
- data/lib/shamu/features/toggle_codec.rb +69 -0
- data/lib/shamu/features.rb +16 -0
- data/lib/shamu/locale/en.yml +22 -2
- data/lib/shamu/logger.rb +13 -0
- data/lib/shamu/rack/README.md +0 -0
- data/lib/shamu/rack/cookies.rb +115 -0
- data/lib/shamu/rack/cookies_middleware.rb +26 -0
- data/lib/shamu/rack/query_params.rb +41 -0
- data/lib/shamu/rack/query_params_middleware.rb +24 -0
- data/lib/shamu/rack.rb +12 -0
- data/lib/shamu/rails/controller.rb +131 -0
- data/lib/shamu/rails/entity.rb +168 -0
- data/lib/shamu/rails/features.rb +13 -0
- data/lib/shamu/rails/railtie.rb +30 -0
- data/lib/shamu/rails.rb +10 -0
- data/lib/shamu/rspec/matchers.rb +44 -0
- data/lib/shamu/rspec.rb +1 -0
- data/lib/shamu/security/README.md +0 -0
- data/lib/shamu/security/active_record_policy.rb +106 -0
- data/lib/shamu/security/error.rb +65 -0
- data/lib/shamu/security/hashed_value.rb +71 -0
- data/lib/shamu/security/no_policy.rb +15 -0
- data/lib/shamu/security/policy.rb +289 -0
- data/lib/shamu/security/policy_refinement.rb +50 -0
- data/lib/shamu/security/policy_rule.rb +59 -0
- data/lib/shamu/security/principal.rb +72 -0
- data/lib/shamu/security/roles.rb +62 -0
- data/lib/shamu/security/roles_service.rb +30 -0
- data/lib/shamu/security/support.rb +83 -0
- data/lib/shamu/security.rb +43 -0
- data/lib/shamu/services/README.md +2 -0
- data/lib/shamu/services/active_record.rb +58 -0
- data/lib/shamu/services/active_record_crud.rb +378 -0
- data/lib/shamu/services/error.rb +24 -0
- data/lib/shamu/services/lazy_association.rb +31 -0
- data/lib/shamu/services/lazy_transform.rb +97 -0
- data/lib/shamu/services/request.rb +122 -0
- data/lib/shamu/services/request_support.rb +124 -0
- data/lib/shamu/services/result.rb +75 -0
- data/lib/shamu/services/service.rb +355 -0
- data/lib/shamu/services.rb +12 -0
- data/lib/shamu/sessions/README.md +2 -0
- data/lib/shamu/sessions/cookie_store.rb +79 -0
- data/lib/shamu/sessions/session_store.rb +42 -0
- data/lib/shamu/sessions.rb +8 -0
- data/lib/shamu/to_bool_extension.rb +57 -0
- data/lib/shamu/to_model_id_extension.rb +50 -0
- data/lib/shamu/version.rb +10 -4
- data/lib/shamu.rb +18 -6
- data/shamu.gemspec +21 -10
- data/spec/internal/README.md +4 -0
- data/spec/internal/config/database.yml +3 -0
- data/spec/internal/config/routes.rb +3 -0
- data/spec/internal/db/schema.rb +3 -0
- data/spec/internal/log/.gitignore +1 -0
- data/spec/internal/public/favicon.ico +0 -0
- data/spec/lib/shamu/active_record_support.rb +32 -0
- data/spec/lib/shamu/attributes/assignment_spec.rb +129 -0
- data/spec/lib/shamu/attributes/equality_spec.rb +63 -0
- data/spec/lib/shamu/attributes/fluid_assignment_spec.rb +31 -0
- data/spec/lib/shamu/attributes/validation_spec.rb +53 -0
- data/spec/lib/shamu/attributes_spec.rb +331 -0
- data/spec/lib/shamu/auditing/logging_auditing_service_spec.rb +18 -0
- data/spec/lib/shamu/auditing/support_spec.rb +41 -0
- data/spec/lib/shamu/entities/active_record_soft_destroy_spec.rb +82 -0
- data/spec/lib/shamu/entities/active_record_spec.rb +66 -0
- data/spec/lib/shamu/entities/entity_path_spec.rb +40 -0
- data/spec/lib/shamu/entities/entity_spec.rb +56 -0
- data/spec/lib/shamu/entities/identity_cache_spec.rb +69 -0
- data/spec/lib/shamu/entities/list_scope/dates_spec.rb +47 -0
- data/spec/lib/shamu/entities/list_scope/paging_spec.rb +41 -0
- data/spec/lib/shamu/entities/list_scope/scoped_paging_spec.rb +40 -0
- data/spec/lib/shamu/entities/list_scope/sorting_spec.rb +59 -0
- data/spec/lib/shamu/entities/list_scope_spec.rb +127 -0
- data/spec/lib/shamu/entities/list_spec.rb +60 -0
- data/spec/lib/shamu/entities/null_entity_spec.rb +94 -0
- data/spec/lib/shamu/events/active_record/migration_spec.rb +11 -0
- data/spec/lib/shamu/events/active_record/service_spec.rb +139 -0
- data/spec/lib/shamu/events/events_service_spec.rb +57 -0
- data/spec/lib/shamu/events/in_memory/async_service_spec.rb +37 -0
- data/spec/lib/shamu/events/in_memory/service_spec.rb +36 -0
- data/spec/lib/shamu/events/message_spec.rb +7 -0
- data/spec/lib/shamu/events/support_spec.rb +44 -0
- data/spec/lib/shamu/features/conditions/condition_spec.rb +8 -0
- data/spec/lib/shamu/features/conditions/env_spec.rb +29 -0
- data/spec/lib/shamu/features/conditions/hosts_spec.rb +21 -0
- data/spec/lib/shamu/features/conditions/matching_spec.rb +23 -0
- data/spec/lib/shamu/features/conditions/percentage_spec.rb +71 -0
- data/spec/lib/shamu/features/conditions/proc_spec.rb +28 -0
- data/spec/lib/shamu/features/env_store_spec.rb +48 -0
- data/spec/lib/shamu/features/features.yml +34 -0
- data/spec/lib/shamu/features/features_service_spec.rb +109 -0
- data/spec/lib/shamu/features/secondary.yml +5 -0
- data/spec/lib/shamu/features/selector_spec.rb +17 -0
- data/spec/lib/shamu/features/support_spec.rb +45 -0
- data/spec/lib/shamu/features/toggle_codec_spec.rb +28 -0
- data/spec/lib/shamu/features/toggle_spec.rb +42 -0
- data/spec/lib/shamu/rack/cookies_middleware_spec.rb +33 -0
- data/spec/lib/shamu/rack/cookies_spec.rb +43 -0
- data/spec/lib/shamu/rack/query_params_middleware_spec.rb +33 -0
- data/spec/lib/shamu/rack/query_params_spec.rb +23 -0
- data/spec/lib/shamu/rails/controller_spec.rb +74 -0
- data/spec/lib/shamu/rails/entity_spec.rb +150 -0
- data/spec/lib/shamu/rails/features.yml +13 -0
- data/spec/lib/shamu/rails/features_spec.rb +45 -0
- data/spec/lib/shamu/security/active_record_policy_spec.rb +38 -0
- data/spec/lib/shamu/security/hashed_value_spec.rb +41 -0
- data/spec/lib/shamu/security/policy_refinement_spec.rb +61 -0
- data/spec/lib/shamu/security/policy_rule_spec.rb +60 -0
- data/spec/lib/shamu/security/policy_spec.rb +158 -0
- data/spec/lib/shamu/security/roles_spec.rb +46 -0
- data/spec/lib/shamu/services/active_record_crud_spec.rb +460 -0
- data/spec/lib/shamu/services/active_record_spec.rb +92 -0
- data/spec/lib/shamu/services/lazy_association_spec.rb +31 -0
- data/spec/lib/shamu/services/lazy_transform_spec.rb +96 -0
- data/spec/lib/shamu/services/request_spec.rb +58 -0
- data/spec/lib/shamu/services/request_support_spec.rb +129 -0
- data/spec/lib/shamu/services/result_spec.rb +37 -0
- data/spec/lib/shamu/services/service_spec.rb +307 -0
- data/spec/lib/shamu/sessions/cookie_store_spec.rb +44 -0
- data/spec/lib/shamu/to_bool_extension_spec.rb +67 -0
- data/spec/lib/shamu/to_model_id_extension_spec.rb +54 -0
- data/spec/rails_helper.rb +13 -0
- data/spec/spec_helper.rb +17 -12
- data/spec/support/active_record.rb +17 -0
- data/spec/support/database.rb +14 -0
- data/spec/support/logger.rb +0 -0
- metadata +383 -9
- data/spec/lib/shamu_spec.rb +0 -5
- /data/{spec/internal/log/test.log → lib/shamu/attributes/README.md} +0 -0
@@ -0,0 +1,122 @@
|
|
1
|
+
module Shamu
|
2
|
+
module Services
|
3
|
+
|
4
|
+
# Define the attributes and validations required to request a change by a
|
5
|
+
# {Service}. You can use the Request in place of an ActiveRecord model in
|
6
|
+
# rails forms_helpers.
|
7
|
+
#
|
8
|
+
# ```
|
9
|
+
# module Document
|
10
|
+
# module Request
|
11
|
+
# class Change < Shamu::Services::Request
|
12
|
+
# attribute :title, presence: true
|
13
|
+
# attribute :author_id, presence: true
|
14
|
+
# end
|
15
|
+
#
|
16
|
+
# class Create < Change
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
# class Update < Change
|
20
|
+
# attribute :id, presence: true
|
21
|
+
# end
|
22
|
+
# end
|
23
|
+
# end
|
24
|
+
# ```
|
25
|
+
class Request
|
26
|
+
include Shamu::Attributes
|
27
|
+
include Shamu::Attributes::Assignment
|
28
|
+
include Shamu::Attributes::FluidAssignment
|
29
|
+
include Shamu::Attributes::Validation
|
30
|
+
|
31
|
+
# Applies the attributes of the request to the given model. Only handles
|
32
|
+
# scalar attributes. For more complex associations, override in a custom
|
33
|
+
# {Request} class.
|
34
|
+
#
|
35
|
+
# @param [Object] model or object to apply the attributes to.
|
36
|
+
# @return [model]
|
37
|
+
def apply_to( model )
|
38
|
+
self.class.attributes.each do |name, _|
|
39
|
+
method = :"#{ name }="
|
40
|
+
model.send method, send( name ) if model.respond_to?( method ) && set?( name )
|
41
|
+
end
|
42
|
+
|
43
|
+
model
|
44
|
+
end
|
45
|
+
|
46
|
+
# Entities are always immutable - so they are considered persisted. Use a
|
47
|
+
# {Services::ChangeRequest} to back a form instead.
|
48
|
+
def persisted?
|
49
|
+
if respond_to?( :id )
|
50
|
+
!!id
|
51
|
+
else
|
52
|
+
fail NotImplementedError, "override persisted? in #{ self.class.name }"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
class << self
|
57
|
+
# Coerces a hash or params object to a proper {Request} object.
|
58
|
+
# @param [Object] params to be coerced.
|
59
|
+
# @return [Request] the coerced request.
|
60
|
+
def coerce( params )
|
61
|
+
if params.is_a?( self )
|
62
|
+
params
|
63
|
+
elsif params.respond_to?( :to_h ) || params.respond_to?( :to_attributes )
|
64
|
+
new( params )
|
65
|
+
elsif params.nil?
|
66
|
+
new
|
67
|
+
else
|
68
|
+
raise ArgumentError
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Coerces the given params object and raises an ArgumentError if any of
|
73
|
+
# the parameters are invalid.
|
74
|
+
# @param (see .coerce)
|
75
|
+
# @return (see .coerce)
|
76
|
+
def coerce!( params )
|
77
|
+
coerced = coerce( params )
|
78
|
+
raise ArgumentError unless coerced.valid?
|
79
|
+
coerced
|
80
|
+
end
|
81
|
+
|
82
|
+
REQUEST_ACTION_PATTERN = /(Create|Update|New|Change|Delete)?(Request)?$/
|
83
|
+
|
84
|
+
# @return [ActiveModel::Name] used by url_helpers or form_helpers etc.
|
85
|
+
# when generating model specific names for this request.
|
86
|
+
def model_name
|
87
|
+
@model_name ||= begin
|
88
|
+
base_name = name || ""
|
89
|
+
parts = reduce_model_name_parts( base_name.split( "::" ) )
|
90
|
+
parts = ["Request"] if parts.empty?
|
91
|
+
base_name = parts.join "::"
|
92
|
+
|
93
|
+
::ActiveModel::Name.new( self, nil, base_name )
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
def reduce_model_name_parts( parts )
|
100
|
+
while last = parts.last
|
101
|
+
if last == "Request"
|
102
|
+
parts[-1] = parts[-2].singularize
|
103
|
+
break
|
104
|
+
end
|
105
|
+
|
106
|
+
last.sub! REQUEST_ACTION_PATTERN, ""
|
107
|
+
if last.empty?
|
108
|
+
parts.pop
|
109
|
+
next
|
110
|
+
end
|
111
|
+
|
112
|
+
last = last.singularize
|
113
|
+
parts[-1] = last
|
114
|
+
break
|
115
|
+
end
|
116
|
+
|
117
|
+
parts
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
module Shamu
|
2
|
+
module Services
|
3
|
+
|
4
|
+
# Include into services that support mutating resources to add basic
|
5
|
+
# {#with_request} and {Request} conventions.
|
6
|
+
module RequestSupport
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
# Used to interrogate the service for the {Request} class to use for a
|
10
|
+
# given method.
|
11
|
+
#
|
12
|
+
# Combine with {Request#init_from} to prepare a request for use in a rails
|
13
|
+
# form ready to modify an existing entity.
|
14
|
+
#
|
15
|
+
# @param [Symbol] method on the service that will be called.
|
16
|
+
# @return [Class] a class that inherits from Request.
|
17
|
+
def request_class( method )
|
18
|
+
self.class.request_class( method )
|
19
|
+
end
|
20
|
+
|
21
|
+
# Build a {Request} object, prepopulated with the current state of the
|
22
|
+
# resource to submit changes to the given `method`.
|
23
|
+
#
|
24
|
+
# @param [Symbol] method that will be called with the generated request.
|
25
|
+
# @param [Entities::Entity] entity optional entity that will modified.
|
26
|
+
# @return [Request]
|
27
|
+
def request_for( method, entity = nil )
|
28
|
+
request = request_class( method ).new( entity )
|
29
|
+
request.id = entity.id if entity && request.attribute?( :id )
|
30
|
+
|
31
|
+
request
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
# @!visibility public
|
37
|
+
#
|
38
|
+
# Respond to a {Request} returning a {Result} touple of the subject
|
39
|
+
# {Entities::Entity} and {Request}.
|
40
|
+
#
|
41
|
+
# Before processing the `params` will be coerced and validated. If the
|
42
|
+
# request is invalid, the method will immediately return without
|
43
|
+
# yielding to the block.
|
44
|
+
#
|
45
|
+
# If the block yields an {Entities::Entity} it will be assigned as the
|
46
|
+
# {Result#entity} in the returned {Result} object.
|
47
|
+
#
|
48
|
+
# @param [Request,Hash] params of the request.
|
49
|
+
# @param [Class] request_class to coerce `params` to.
|
50
|
+
# @yield (request)
|
51
|
+
# @yieldparam [Request] request coerced and validated from `params`.
|
52
|
+
# @yieldreturn [Entities::Entity,#errors] the entity manipulated during
|
53
|
+
# the request or an object that responds to #errors.
|
54
|
+
# @return [Result]
|
55
|
+
# @example
|
56
|
+
# def process_order( params )
|
57
|
+
# with_request params, ProcesOrderRequest do |request|
|
58
|
+
# order = Models::Order.find( request.id )
|
59
|
+
#
|
60
|
+
# # Custom validation
|
61
|
+
# next error( :base, "can't do that" ) if order.state == 'processed'
|
62
|
+
#
|
63
|
+
# request.apply_to( order )
|
64
|
+
#
|
65
|
+
# # If DB only validations fail, return errors
|
66
|
+
# next order unless order.save
|
67
|
+
#
|
68
|
+
# # All good, return an entity for the order
|
69
|
+
# scorpion.fetch OrderEntity, { order: order }, {}
|
70
|
+
# end
|
71
|
+
# end
|
72
|
+
def with_request( params, request_class, &block )
|
73
|
+
request = request_class.coerce( params )
|
74
|
+
sources = yield( request ) if request.valid?
|
75
|
+
|
76
|
+
result request, *sources
|
77
|
+
end
|
78
|
+
|
79
|
+
# Static methods added to {RequestSupport}
|
80
|
+
class_methods do
|
81
|
+
|
82
|
+
# (see #request_class)
|
83
|
+
def request_class( method )
|
84
|
+
result = request_class_by_name( method ) \
|
85
|
+
|| request_class_by_alias( method ) \
|
86
|
+
|| request_class_default
|
87
|
+
|
88
|
+
result ||= superclass.request_class( method ) if superclass.respond_to?( :request_class )
|
89
|
+
|
90
|
+
result || fail( IncompleteSetupError, "No Shamu::Services::Request classes defined for '#{ name }'." )
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
def request_class_namespace
|
96
|
+
@request_class_namespace ||= ( name || "" ).sub( /(Service)?$/, "Request" ).constantize
|
97
|
+
rescue NameError
|
98
|
+
self
|
99
|
+
end
|
100
|
+
|
101
|
+
def request_class_by_name( method )
|
102
|
+
camelized = method.to_s.camelize
|
103
|
+
request_class_namespace.const_get( camelized ) if request_class_namespace.const_defined?( camelized )
|
104
|
+
end
|
105
|
+
|
106
|
+
def request_class_by_alias( method )
|
107
|
+
candidate =
|
108
|
+
case method
|
109
|
+
when :new then "Create"
|
110
|
+
when :edit then "Update"
|
111
|
+
end
|
112
|
+
|
113
|
+
if candidate && request_class_namespace.const_defined?( candidate )
|
114
|
+
request_class_namespace.const_get( candidate )
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def request_class_default
|
119
|
+
request_class_namespace.const_get( "Change" ) if request_class_namespace.const_defined?( "Change" )
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module Shamu
|
2
|
+
module Services
|
3
|
+
|
4
|
+
# The result of a {Service} {Request} capturing the validation errors
|
5
|
+
# recorded while processing the request and the resulting
|
6
|
+
# {Services::Entities::Entity} and {Request} used.
|
7
|
+
class Result
|
8
|
+
extend ActiveModel::Translation
|
9
|
+
|
10
|
+
# ============================================================================
|
11
|
+
# @!group Attributes
|
12
|
+
#
|
13
|
+
|
14
|
+
# @return [Request] the request submitted to the {Service}.
|
15
|
+
attr_reader :request
|
16
|
+
|
17
|
+
# @return [Entities::Entity] the entity created or changed by the request.
|
18
|
+
attr_reader :entity
|
19
|
+
|
20
|
+
#
|
21
|
+
# @!endgroup Attributes
|
22
|
+
|
23
|
+
# @param [Array<#errors>] validation_sources an array of objects that respond to `#errors`
|
24
|
+
# returning a {ActiveModel::Errors} object.
|
25
|
+
# @param [Request] request submitted to the service. If :not_set, uses
|
26
|
+
# the first {Request} object found in the `validation_sources`.
|
27
|
+
# @param [Entities::Entity] entity submitted to the service. If :not_set,
|
28
|
+
# uses the first {Request} object found in the `validation_sources`.
|
29
|
+
def initialize( *validation_sources, request: :not_set, entity: :not_set )
|
30
|
+
validation_sources.each do |source|
|
31
|
+
request = source if request == :not_set && source.is_a?( Services::Request )
|
32
|
+
entity = source if entity == :not_set && source.is_a?( Entities::Entity )
|
33
|
+
|
34
|
+
append_error_source source
|
35
|
+
end
|
36
|
+
|
37
|
+
unless request == :not_set
|
38
|
+
@request = request
|
39
|
+
append_error_source request
|
40
|
+
end
|
41
|
+
|
42
|
+
unless entity == :not_set
|
43
|
+
@entity = entity
|
44
|
+
append_error_source entity
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# @return [Boolean] true if there were not recorded errors.
|
49
|
+
def valid?
|
50
|
+
errors.empty?
|
51
|
+
end
|
52
|
+
|
53
|
+
# @return [ActiveModel::Errors] errors gathered from all the validation sources.
|
54
|
+
# Typically the {#request} and {#entity}.
|
55
|
+
def errors
|
56
|
+
@errors ||= ActiveModel::Errors.new( self )
|
57
|
+
end
|
58
|
+
|
59
|
+
# Delegate model_name to request/entity
|
60
|
+
def model_name
|
61
|
+
( request && request.model_name ) || ( entity && entity.model_name ) || ActiveModel::Name.new( self, nil, "Request" ) # rubocop:disable Metrics/LineLength
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def append_error_source( source )
|
67
|
+
return unless source.respond_to?( :errors )
|
68
|
+
|
69
|
+
source.errors.each do |attr, message|
|
70
|
+
errors.add attr, message unless errors[attr].include? message
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,355 @@
|
|
1
|
+
require "scorpion"
|
2
|
+
require "shamu/logger"
|
3
|
+
|
4
|
+
module Shamu
|
5
|
+
module Services
|
6
|
+
|
7
|
+
# ...
|
8
|
+
#
|
9
|
+
# ## Well Known Methos
|
10
|
+
#
|
11
|
+
# - __list( list_scope )__ - lists all of the entities matching the
|
12
|
+
# requested {Entities::ListScope list scope}. Often apply
|
13
|
+
# {Entities::ListScope::Paging} or other filters.
|
14
|
+
#
|
15
|
+
# ```
|
16
|
+
# def list( list_scope )
|
17
|
+
# list_scope = UserListScope.coerce! list_scope
|
18
|
+
# entity_list Models::User.by_list_scope( list_scope ) do |record|
|
19
|
+
# scorpion.fetch UserEntity, record: record
|
20
|
+
# end
|
21
|
+
# end
|
22
|
+
# ```
|
23
|
+
#
|
24
|
+
# - __lookup( *ids )__ - matches a given list of ids to their entities or a
|
25
|
+
# {Entities::NullEntity} for ids that can't be found. The `lookup` method
|
26
|
+
# is typically used to resolve related resources between services -
|
27
|
+
# similar to associations in an ActiveRecord model. Use
|
28
|
+
# {#entity_lookup_list} to transform a list of records or external
|
29
|
+
# resources to a lookup list of entities.
|
30
|
+
#
|
31
|
+
# ```
|
32
|
+
# def lookup( *ids )
|
33
|
+
# entity_lookup_list Models::User.where( id: ids ), ids, NullEntity.for( UserEntity ) do |record|
|
34
|
+
# scorpion.fetch UserEntity, record: record
|
35
|
+
# end
|
36
|
+
# end
|
37
|
+
# ```
|
38
|
+
#
|
39
|
+
# - __find( id )__ - finds a single entity with the given id, raising
|
40
|
+
# {Shamu::NotFoundError} if the resource cannot be found. If the service
|
41
|
+
# also implements `lookup` then this can be implemented by simply aliasing
|
42
|
+
# `find` to {#find_by_lookup}.
|
43
|
+
#
|
44
|
+
# ```
|
45
|
+
# def find( id )
|
46
|
+
# find_by_lookup( id )
|
47
|
+
# end
|
48
|
+
# ```
|
49
|
+
#
|
50
|
+
# - __report( report_scope )__ - Compile a report including metrics, and
|
51
|
+
# master/detail data that make take longer to gather than a standard
|
52
|
+
# `list` request.
|
53
|
+
#
|
54
|
+
# ```
|
55
|
+
# def report( report_scope = nil )
|
56
|
+
# report_scope = UserReportScope.coerce! report_scope
|
57
|
+
# scorpion.fetch UserReport, report_scope, {}
|
58
|
+
# end
|
59
|
+
# ```
|
60
|
+
class Service
|
61
|
+
|
62
|
+
# Support dependency injection for related services.
|
63
|
+
include Scorpion::Object
|
64
|
+
|
65
|
+
# ============================================================================
|
66
|
+
# @!group Dependencies
|
67
|
+
#
|
68
|
+
|
69
|
+
# @!attribute
|
70
|
+
# @return [Shamu::Logger] the IO to dump logging info to.
|
71
|
+
attr_dependency :logger, Shamu::Logger
|
72
|
+
|
73
|
+
#
|
74
|
+
# @!endgroup Dependencies
|
75
|
+
|
76
|
+
initialize do
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
# @!visibility public
|
82
|
+
# Takes a raw enumerable list of records and transforms them to a proper
|
83
|
+
# {Entities::List}.
|
84
|
+
#
|
85
|
+
# As simple as the method is, it also serves as a hook for mixins to add
|
86
|
+
# additional behavior when processing lists.
|
87
|
+
#
|
88
|
+
# If a block is not provided, looks for a method `build_entity(record,
|
89
|
+
# records=nil)` where `record` is the individual record to be
|
90
|
+
# transformed and `records` is the original collection or database query
|
91
|
+
# being transformed.
|
92
|
+
#
|
93
|
+
# @param [Enumerable] records the raw list of records.
|
94
|
+
# @yield (record)
|
95
|
+
# @yieldparam [Object] record the raw value from the `list` to to
|
96
|
+
# transform to an {Entities::Entity}.
|
97
|
+
# @yieldreturn [Entities::Entity]
|
98
|
+
# @return [Entities::List]
|
99
|
+
def entity_list( records, &transformer )
|
100
|
+
return Entities::List.new [] unless records
|
101
|
+
unless transformer
|
102
|
+
fail "Either provide a block or add a private method `def build_entity( record, records = nil )` to #{ self.class.name }." unless respond_to?( :build_entity, true ) # rubocop:disable Metrics/LineLength
|
103
|
+
transformer ||= ->( record ) { build_entity( record, records ) }
|
104
|
+
end
|
105
|
+
|
106
|
+
Entities::List.new LazyTransform.new( records, &transformer )
|
107
|
+
end
|
108
|
+
|
109
|
+
# @!visibility public
|
110
|
+
# Match a list of records with the ids used to look up those records.
|
111
|
+
# Uses a {Entities::NullEntity} if the id doesn't have a matching record.
|
112
|
+
#
|
113
|
+
# @param [Enumerable] records matching the requested `ids`.
|
114
|
+
# @param [Array<Integer>] ids of records found.
|
115
|
+
# @param [Class] null_class to use when an id doesn't have a matching
|
116
|
+
# record.
|
117
|
+
# @param [Symbol,#call(record)] match the attribute or a Proc used to
|
118
|
+
# extract the id used to compare records.
|
119
|
+
# @param [Symbol,#call] coerce a method that can be used to coerce id values
|
120
|
+
# to the same type (eg :to_i). If not set, automatically uses :to_i
|
121
|
+
# if match is an 'id' attribute.
|
122
|
+
# @yield (see #entity_list)
|
123
|
+
# @yieldparam (see #entity_list)
|
124
|
+
# @yieldreturn (see #entity_list)
|
125
|
+
# @return [Entities::List]
|
126
|
+
#
|
127
|
+
# @example
|
128
|
+
# def lookup( *ids )
|
129
|
+
# records = Models::Favorite.all.where( id: ids )
|
130
|
+
# entity_lookup_list records, ids, NullEntity.for( FavoriteEntity ) do |record|
|
131
|
+
# scorpion.fetch FavoriteEntity, { record: record }, {}
|
132
|
+
# end
|
133
|
+
# end
|
134
|
+
#
|
135
|
+
# def lookup_by_name( *names )
|
136
|
+
# records = Models::Favorite.all.where( :name.in( names ) )
|
137
|
+
#
|
138
|
+
# entity_lookup_list records, names, NullEntity.for( FavoriteEntity ), match: :name do |record|
|
139
|
+
# scorpion.fetch FavoriteEntity, { record: record }, {}
|
140
|
+
# end
|
141
|
+
# end
|
142
|
+
#
|
143
|
+
# def lookup_by_lowercase( *names )
|
144
|
+
# records = Models::Favorite.all.where( :name.in( names.map( &:downcase ) ) )
|
145
|
+
# matcher = ->( record ) { record.name.downcase }
|
146
|
+
#
|
147
|
+
# entity_lookup_list records, names, NullEntity.for( FavoriteEntity ), match: matcher do |record|
|
148
|
+
# scorpion.fetch FavoriteEntity, { record: record }, {}
|
149
|
+
# end
|
150
|
+
# end
|
151
|
+
#
|
152
|
+
#
|
153
|
+
def entity_lookup_list( records, ids, null_class, match: :id, coerce: :not_set, &transformer )
|
154
|
+
matcher = entity_lookup_list_matcher( match )
|
155
|
+
coerce = coerce_method( coerce, match )
|
156
|
+
ids = ids.map( &coerce ) if coerce
|
157
|
+
|
158
|
+
list = entity_list records, &transformer
|
159
|
+
matched = ids.map do |id|
|
160
|
+
list.find { |e| matcher.call( e ) == id } || scorpion.fetch( null_class, { id: id }, {} )
|
161
|
+
end
|
162
|
+
|
163
|
+
Entities::List.new( matched )
|
164
|
+
end
|
165
|
+
|
166
|
+
def entity_lookup_list_matcher( match )
|
167
|
+
if !match.is_a?( Symbol ) && match.respond_to?( :call )
|
168
|
+
match
|
169
|
+
elsif match == :id
|
170
|
+
->( record ) { record && record.id }
|
171
|
+
else
|
172
|
+
->( record ) { record && record.send( match ) }
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def coerce_method( coerce, match )
|
177
|
+
return coerce unless coerce == :not_set
|
178
|
+
:to_i if match.is_a?( Symbol ) && match =~ /(^|_)id$/
|
179
|
+
end
|
180
|
+
|
181
|
+
# @!visibility public
|
182
|
+
#
|
183
|
+
# For services that expose a standard `lookup` method, find_by_lookup
|
184
|
+
# looks up a single entity and raises {Shamu::NotFoundError} if the
|
185
|
+
# entity is nil or a {Entities::NullEntity}.
|
186
|
+
#
|
187
|
+
# A `find` method can then be implemented in terms of the `lookup`
|
188
|
+
# method.
|
189
|
+
#
|
190
|
+
# @param [Integer] id of the entity.
|
191
|
+
# @return [Entities::Entity]
|
192
|
+
#
|
193
|
+
# @example
|
194
|
+
#
|
195
|
+
# class Example < Services::Service
|
196
|
+
# def lookup( *ids )
|
197
|
+
# # do something to find the entity
|
198
|
+
# end
|
199
|
+
#
|
200
|
+
# def find( id )
|
201
|
+
# find_by_lookup( id )
|
202
|
+
# end
|
203
|
+
# end
|
204
|
+
def find_by_lookup( id )
|
205
|
+
entity = lookup( id ).first
|
206
|
+
raise Shamu::NotFoundError unless entity.present?
|
207
|
+
entity
|
208
|
+
end
|
209
|
+
|
210
|
+
# @!visibility public
|
211
|
+
#
|
212
|
+
# Find an associated entity from a dependent service. Attempts to
|
213
|
+
# effeciently handle multiple requests to lookup associations by caching
|
214
|
+
# all the associated entities when {#lookup_association} is used
|
215
|
+
# repeatedly when building an entity.
|
216
|
+
#
|
217
|
+
# @param [Object] id of the associated {Entities::Entity} to find.
|
218
|
+
# @param [Service] service used to locate the associated resource.
|
219
|
+
# @return [Entity] the found entity or a {Entities::NullEntity} if the
|
220
|
+
# association doesn't exist.
|
221
|
+
#
|
222
|
+
# @example
|
223
|
+
#
|
224
|
+
# def build_entity( record, records = nil )
|
225
|
+
# owner = lookup_association record.owner_id, users_service do
|
226
|
+
# records.pluck( :owner_id ) if records
|
227
|
+
# end
|
228
|
+
#
|
229
|
+
# scorpion.fetch UserEntity, { record: record, owner: owner }, {}
|
230
|
+
# end
|
231
|
+
def lookup_association( id, service, &block )
|
232
|
+
return unless id
|
233
|
+
|
234
|
+
cache = cache_for( entity: service )
|
235
|
+
cache.fetch( id ) || begin
|
236
|
+
if block_given? && ( ids = yield )
|
237
|
+
service.lookup( *ids ).map do |entity|
|
238
|
+
cache.add( entity.id, entity )
|
239
|
+
end
|
240
|
+
|
241
|
+
cache.fetch( id )
|
242
|
+
else
|
243
|
+
association = service.lookup( id ).first
|
244
|
+
cache.add( association.id, association )
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
# @!visibility public
|
250
|
+
#
|
251
|
+
# Perform a lazy {#lookup_association} and only load the entity if its
|
252
|
+
# actually dereferenced by the caller.
|
253
|
+
#
|
254
|
+
# @param (see #lookup_association)
|
255
|
+
# @return [LazyAssociation<Entity>]
|
256
|
+
def lazy_association( id, service, &block )
|
257
|
+
LazyAssociation.new( id ) do
|
258
|
+
lookup_association id, service, &block
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
# @!visibility public
|
263
|
+
#
|
264
|
+
# Get the {Entities::IdentityCache} for the given {Entities::Entity} class.
|
265
|
+
# @param [Class] entity the type of entity that will be cached. Only
|
266
|
+
# required if the service manages multiple entities.
|
267
|
+
# @param [Symbol,#call] key the attribute on the entity, or a custom
|
268
|
+
# block used to obtain the cache key from an entity.
|
269
|
+
# @param [Symbol,#call] coerce a method that can be used to coerce key values
|
270
|
+
# to the same type (eg :to_i). If not set, automatically uses :to_i
|
271
|
+
# if key is an 'id' attribute.
|
272
|
+
# @return [Entities::IdentityCache]
|
273
|
+
def cache_for( key: :id, entity: nil, coerce: :not_set )
|
274
|
+
coerce = coerce_method( coerce, key )
|
275
|
+
|
276
|
+
cache_key = [ entity, key, coerce ]
|
277
|
+
@entity_caches ||= {}
|
278
|
+
@entity_caches[ cache_key ] ||= scorpion.fetch( Entities::IdentityCache, coerce )
|
279
|
+
end
|
280
|
+
|
281
|
+
# @!visibility public
|
282
|
+
#
|
283
|
+
# Caches the results of looking up the given ids in an {Entities::IdentityCache}
|
284
|
+
# and only fetches the records that have not yet been cached.
|
285
|
+
#
|
286
|
+
# @param (see #cache_for)
|
287
|
+
# @param [Array] ids to fetch.
|
288
|
+
# @yield (missing_ids)
|
289
|
+
# @yieldparam [Array] missing_ids that have not been cached yet.
|
290
|
+
# @yieldreturn [Entities::List] the list of entities for the missing ids.
|
291
|
+
#
|
292
|
+
# @example
|
293
|
+
#
|
294
|
+
# def lookup( *ids )
|
295
|
+
# cached_lookup( ids ) do |missing_ids|
|
296
|
+
# entity_lookup_list( Models::User.where( id: missing_ids ), missing_ids, UserEntity::Missing )
|
297
|
+
# end
|
298
|
+
# end
|
299
|
+
def cached_lookup( ids, match: :id, coerce: :not_set, entity: nil, &lookup )
|
300
|
+
cache = cache_for( key: match, coerce: coerce, entity: entity )
|
301
|
+
missing_ids = cache.uncached_keys( ids )
|
302
|
+
|
303
|
+
cache_entities( cache, match, missing_ids, &lookup ) if missing_ids.any?
|
304
|
+
|
305
|
+
entities = ids.map { |id| cache.fetch( id ) || fail( Shamu::NotFoundError ) }
|
306
|
+
Entities::List.new( entities )
|
307
|
+
end
|
308
|
+
|
309
|
+
def cache_entities( cache, match, missing_ids, &lookup )
|
310
|
+
matcher = entity_lookup_list_matcher( match )
|
311
|
+
if list = yield( missing_ids )
|
312
|
+
list.each do |e|
|
313
|
+
if e.empty?
|
314
|
+
# For NullEntitty, the id for a custom field or matcher will
|
315
|
+
# still always be assigned to the entity id.
|
316
|
+
cache.add( e.id, e )
|
317
|
+
else
|
318
|
+
cache.add( matcher.call( e ), e )
|
319
|
+
end
|
320
|
+
end
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
# @!visibility public
|
325
|
+
#
|
326
|
+
# @overload result( *validation_sources, request: nil, entity: nil )
|
327
|
+
# @param (see Result#initialize)
|
328
|
+
# @return [Result]
|
329
|
+
def result( *args )
|
330
|
+
Result.new( *args )
|
331
|
+
end
|
332
|
+
|
333
|
+
# @!visibility public
|
334
|
+
#
|
335
|
+
# Return an error {#result} from a service request.
|
336
|
+
# @overload error( attribute, message )
|
337
|
+
# @param (see ErrorResult#initialize)
|
338
|
+
# @return [ErrorResult]
|
339
|
+
def error( *args )
|
340
|
+
Result.new.tap do |r|
|
341
|
+
r.errors.add( *args )
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
# @param [String,Integer,#to_model_id] value
|
346
|
+
# @return [Boolean] true if the value looks like an ID.
|
347
|
+
def model_id?( value )
|
348
|
+
case Array( value ).first
|
349
|
+
when Integer then true
|
350
|
+
when String then ToModelIdExtension::Strings::NUMERIC_PATTERN =~ value
|
351
|
+
end
|
352
|
+
end
|
353
|
+
end
|
354
|
+
end
|
355
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module Shamu
|
2
|
+
# {include:file:lib/shamu/services/README.md}
|
3
|
+
module Services
|
4
|
+
require "shamu/services/error"
|
5
|
+
require "shamu/services/service"
|
6
|
+
require "shamu/services/request"
|
7
|
+
require "shamu/services/request_support"
|
8
|
+
require "shamu/services/result"
|
9
|
+
require "shamu/services/lazy_transform"
|
10
|
+
require "shamu/services/lazy_association"
|
11
|
+
end
|
12
|
+
end
|