shamu 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (207) hide show
  1. checksums.yaml +4 -4
  2. data/.codeclimate.yml +26 -0
  3. data/.gitignore +2 -1
  4. data/.rubocop.yml +89 -30
  5. data/.yardopts +4 -5
  6. data/Gemfile +24 -12
  7. data/Guardfile +5 -0
  8. data/LABELS.md +22 -0
  9. data/README.md +41 -0
  10. data/Rakefile +12 -0
  11. data/circle.yml +7 -3
  12. data/config.ru +7 -0
  13. data/lib/shamu/active_record.rb +7 -0
  14. data/lib/shamu/attributes/assignment.rb +114 -0
  15. data/lib/shamu/attributes/equality.rb +40 -0
  16. data/lib/shamu/attributes/fluid_assignment.rb +49 -0
  17. data/lib/shamu/attributes/validation.rb +74 -0
  18. data/lib/shamu/attributes.rb +255 -0
  19. data/lib/shamu/auditing/README.md +0 -0
  20. data/lib/shamu/auditing/audit_record.rb +32 -0
  21. data/lib/shamu/auditing/auditing_service.rb +32 -0
  22. data/lib/shamu/auditing/list_scope.rb +22 -0
  23. data/lib/shamu/auditing/logging_auditing_service.rb +16 -0
  24. data/lib/shamu/auditing/support.rb +75 -0
  25. data/lib/shamu/auditing/transaction.rb +58 -0
  26. data/lib/shamu/auditing.rb +12 -0
  27. data/lib/shamu/entities/README.md +1 -0
  28. data/lib/shamu/entities/active_record.rb +123 -0
  29. data/lib/shamu/entities/active_record_soft_destroy.rb +91 -0
  30. data/lib/shamu/entities/entity.rb +196 -0
  31. data/lib/shamu/entities/entity_path.rb +87 -0
  32. data/lib/shamu/entities/identity_cache.rb +64 -0
  33. data/lib/shamu/entities/list.rb +54 -0
  34. data/lib/shamu/entities/list_scope/dates.rb +57 -0
  35. data/lib/shamu/entities/list_scope/paging.rb +51 -0
  36. data/lib/shamu/entities/list_scope/scoped_paging.rb +65 -0
  37. data/lib/shamu/entities/list_scope/sorting.rb +76 -0
  38. data/lib/shamu/entities/list_scope.rb +105 -0
  39. data/lib/shamu/entities/null_entity.rb +88 -0
  40. data/lib/shamu/entities.rb +11 -0
  41. data/lib/shamu/error.rb +23 -5
  42. data/lib/shamu/events/README.md +0 -0
  43. data/lib/shamu/events/active_record/channel.rb +36 -0
  44. data/lib/shamu/events/active_record/message.rb +52 -0
  45. data/lib/shamu/events/active_record/migration.rb +49 -0
  46. data/lib/shamu/events/active_record/runner.rb +28 -0
  47. data/lib/shamu/events/active_record/service.rb +174 -0
  48. data/lib/shamu/events/active_record.rb +13 -0
  49. data/lib/shamu/events/channel_stats.rb +23 -0
  50. data/lib/shamu/events/error.rb +24 -0
  51. data/lib/shamu/events/events_service.rb +136 -0
  52. data/lib/shamu/events/in_memory/async_service.rb +48 -0
  53. data/lib/shamu/events/in_memory/service.rb +97 -0
  54. data/lib/shamu/events/in_memory.rb +10 -0
  55. data/lib/shamu/events/message.rb +38 -0
  56. data/lib/shamu/events/support.rb +60 -0
  57. data/lib/shamu/events.rb +12 -0
  58. data/lib/shamu/features/README.md +0 -0
  59. data/lib/shamu/features/conditions/condition.rb +39 -0
  60. data/lib/shamu/features/conditions/env.rb +37 -0
  61. data/lib/shamu/features/conditions/hosts.rb +25 -0
  62. data/lib/shamu/features/conditions/matching.rb +16 -0
  63. data/lib/shamu/features/conditions/not_matching.rb +16 -0
  64. data/lib/shamu/features/conditions/percentage.rb +44 -0
  65. data/lib/shamu/features/conditions/proc.rb +54 -0
  66. data/lib/shamu/features/conditions/roles.rb +23 -0
  67. data/lib/shamu/features/conditions/schedule_at.rb +27 -0
  68. data/lib/shamu/features/conditions.rb +18 -0
  69. data/lib/shamu/features/config_service.rb +10 -0
  70. data/lib/shamu/features/context.rb +80 -0
  71. data/lib/shamu/features/env_store.rb +88 -0
  72. data/lib/shamu/features/errors.rb +29 -0
  73. data/lib/shamu/features/features_service.rb +168 -0
  74. data/lib/shamu/features/list_scope.rb +30 -0
  75. data/lib/shamu/features/selector.rb +50 -0
  76. data/lib/shamu/features/support.rb +51 -0
  77. data/lib/shamu/features/toggle.rb +149 -0
  78. data/lib/shamu/features/toggle_codec.rb +69 -0
  79. data/lib/shamu/features.rb +16 -0
  80. data/lib/shamu/locale/en.yml +22 -2
  81. data/lib/shamu/logger.rb +13 -0
  82. data/lib/shamu/rack/README.md +0 -0
  83. data/lib/shamu/rack/cookies.rb +115 -0
  84. data/lib/shamu/rack/cookies_middleware.rb +26 -0
  85. data/lib/shamu/rack/query_params.rb +41 -0
  86. data/lib/shamu/rack/query_params_middleware.rb +24 -0
  87. data/lib/shamu/rack.rb +12 -0
  88. data/lib/shamu/rails/controller.rb +131 -0
  89. data/lib/shamu/rails/entity.rb +168 -0
  90. data/lib/shamu/rails/features.rb +13 -0
  91. data/lib/shamu/rails/railtie.rb +30 -0
  92. data/lib/shamu/rails.rb +10 -0
  93. data/lib/shamu/rspec/matchers.rb +44 -0
  94. data/lib/shamu/rspec.rb +1 -0
  95. data/lib/shamu/security/README.md +0 -0
  96. data/lib/shamu/security/active_record_policy.rb +106 -0
  97. data/lib/shamu/security/error.rb +65 -0
  98. data/lib/shamu/security/hashed_value.rb +71 -0
  99. data/lib/shamu/security/no_policy.rb +15 -0
  100. data/lib/shamu/security/policy.rb +289 -0
  101. data/lib/shamu/security/policy_refinement.rb +50 -0
  102. data/lib/shamu/security/policy_rule.rb +59 -0
  103. data/lib/shamu/security/principal.rb +72 -0
  104. data/lib/shamu/security/roles.rb +62 -0
  105. data/lib/shamu/security/roles_service.rb +30 -0
  106. data/lib/shamu/security/support.rb +83 -0
  107. data/lib/shamu/security.rb +43 -0
  108. data/lib/shamu/services/README.md +2 -0
  109. data/lib/shamu/services/active_record.rb +58 -0
  110. data/lib/shamu/services/active_record_crud.rb +378 -0
  111. data/lib/shamu/services/error.rb +24 -0
  112. data/lib/shamu/services/lazy_association.rb +31 -0
  113. data/lib/shamu/services/lazy_transform.rb +97 -0
  114. data/lib/shamu/services/request.rb +122 -0
  115. data/lib/shamu/services/request_support.rb +124 -0
  116. data/lib/shamu/services/result.rb +75 -0
  117. data/lib/shamu/services/service.rb +355 -0
  118. data/lib/shamu/services.rb +12 -0
  119. data/lib/shamu/sessions/README.md +2 -0
  120. data/lib/shamu/sessions/cookie_store.rb +79 -0
  121. data/lib/shamu/sessions/session_store.rb +42 -0
  122. data/lib/shamu/sessions.rb +8 -0
  123. data/lib/shamu/to_bool_extension.rb +57 -0
  124. data/lib/shamu/to_model_id_extension.rb +50 -0
  125. data/lib/shamu/version.rb +10 -4
  126. data/lib/shamu.rb +18 -6
  127. data/shamu.gemspec +21 -10
  128. data/spec/internal/README.md +4 -0
  129. data/spec/internal/config/database.yml +3 -0
  130. data/spec/internal/config/routes.rb +3 -0
  131. data/spec/internal/db/schema.rb +3 -0
  132. data/spec/internal/log/.gitignore +1 -0
  133. data/spec/internal/public/favicon.ico +0 -0
  134. data/spec/lib/shamu/active_record_support.rb +32 -0
  135. data/spec/lib/shamu/attributes/assignment_spec.rb +129 -0
  136. data/spec/lib/shamu/attributes/equality_spec.rb +63 -0
  137. data/spec/lib/shamu/attributes/fluid_assignment_spec.rb +31 -0
  138. data/spec/lib/shamu/attributes/validation_spec.rb +53 -0
  139. data/spec/lib/shamu/attributes_spec.rb +331 -0
  140. data/spec/lib/shamu/auditing/logging_auditing_service_spec.rb +18 -0
  141. data/spec/lib/shamu/auditing/support_spec.rb +41 -0
  142. data/spec/lib/shamu/entities/active_record_soft_destroy_spec.rb +82 -0
  143. data/spec/lib/shamu/entities/active_record_spec.rb +66 -0
  144. data/spec/lib/shamu/entities/entity_path_spec.rb +40 -0
  145. data/spec/lib/shamu/entities/entity_spec.rb +56 -0
  146. data/spec/lib/shamu/entities/identity_cache_spec.rb +69 -0
  147. data/spec/lib/shamu/entities/list_scope/dates_spec.rb +47 -0
  148. data/spec/lib/shamu/entities/list_scope/paging_spec.rb +41 -0
  149. data/spec/lib/shamu/entities/list_scope/scoped_paging_spec.rb +40 -0
  150. data/spec/lib/shamu/entities/list_scope/sorting_spec.rb +59 -0
  151. data/spec/lib/shamu/entities/list_scope_spec.rb +127 -0
  152. data/spec/lib/shamu/entities/list_spec.rb +60 -0
  153. data/spec/lib/shamu/entities/null_entity_spec.rb +94 -0
  154. data/spec/lib/shamu/events/active_record/migration_spec.rb +11 -0
  155. data/spec/lib/shamu/events/active_record/service_spec.rb +139 -0
  156. data/spec/lib/shamu/events/events_service_spec.rb +57 -0
  157. data/spec/lib/shamu/events/in_memory/async_service_spec.rb +37 -0
  158. data/spec/lib/shamu/events/in_memory/service_spec.rb +36 -0
  159. data/spec/lib/shamu/events/message_spec.rb +7 -0
  160. data/spec/lib/shamu/events/support_spec.rb +44 -0
  161. data/spec/lib/shamu/features/conditions/condition_spec.rb +8 -0
  162. data/spec/lib/shamu/features/conditions/env_spec.rb +29 -0
  163. data/spec/lib/shamu/features/conditions/hosts_spec.rb +21 -0
  164. data/spec/lib/shamu/features/conditions/matching_spec.rb +23 -0
  165. data/spec/lib/shamu/features/conditions/percentage_spec.rb +71 -0
  166. data/spec/lib/shamu/features/conditions/proc_spec.rb +28 -0
  167. data/spec/lib/shamu/features/env_store_spec.rb +48 -0
  168. data/spec/lib/shamu/features/features.yml +34 -0
  169. data/spec/lib/shamu/features/features_service_spec.rb +109 -0
  170. data/spec/lib/shamu/features/secondary.yml +5 -0
  171. data/spec/lib/shamu/features/selector_spec.rb +17 -0
  172. data/spec/lib/shamu/features/support_spec.rb +45 -0
  173. data/spec/lib/shamu/features/toggle_codec_spec.rb +28 -0
  174. data/spec/lib/shamu/features/toggle_spec.rb +42 -0
  175. data/spec/lib/shamu/rack/cookies_middleware_spec.rb +33 -0
  176. data/spec/lib/shamu/rack/cookies_spec.rb +43 -0
  177. data/spec/lib/shamu/rack/query_params_middleware_spec.rb +33 -0
  178. data/spec/lib/shamu/rack/query_params_spec.rb +23 -0
  179. data/spec/lib/shamu/rails/controller_spec.rb +74 -0
  180. data/spec/lib/shamu/rails/entity_spec.rb +150 -0
  181. data/spec/lib/shamu/rails/features.yml +13 -0
  182. data/spec/lib/shamu/rails/features_spec.rb +45 -0
  183. data/spec/lib/shamu/security/active_record_policy_spec.rb +38 -0
  184. data/spec/lib/shamu/security/hashed_value_spec.rb +41 -0
  185. data/spec/lib/shamu/security/policy_refinement_spec.rb +61 -0
  186. data/spec/lib/shamu/security/policy_rule_spec.rb +60 -0
  187. data/spec/lib/shamu/security/policy_spec.rb +158 -0
  188. data/spec/lib/shamu/security/roles_spec.rb +46 -0
  189. data/spec/lib/shamu/services/active_record_crud_spec.rb +460 -0
  190. data/spec/lib/shamu/services/active_record_spec.rb +92 -0
  191. data/spec/lib/shamu/services/lazy_association_spec.rb +31 -0
  192. data/spec/lib/shamu/services/lazy_transform_spec.rb +96 -0
  193. data/spec/lib/shamu/services/request_spec.rb +58 -0
  194. data/spec/lib/shamu/services/request_support_spec.rb +129 -0
  195. data/spec/lib/shamu/services/result_spec.rb +37 -0
  196. data/spec/lib/shamu/services/service_spec.rb +307 -0
  197. data/spec/lib/shamu/sessions/cookie_store_spec.rb +44 -0
  198. data/spec/lib/shamu/to_bool_extension_spec.rb +67 -0
  199. data/spec/lib/shamu/to_model_id_extension_spec.rb +54 -0
  200. data/spec/rails_helper.rb +13 -0
  201. data/spec/spec_helper.rb +17 -12
  202. data/spec/support/active_record.rb +17 -0
  203. data/spec/support/database.rb +14 -0
  204. data/spec/support/logger.rb +0 -0
  205. metadata +383 -9
  206. data/spec/lib/shamu_spec.rb +0 -5
  207. /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
@@ -0,0 +1,2 @@
1
+
2
+ Sessions are awesome.