service_core 0.1.3 → 1.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.
@@ -1,70 +1,74 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "active_support/concern"
4
2
  require "active_model"
5
- require_relative "response"
3
+ require "set"
4
+ require_relative "responder"
6
5
 
7
6
  module ServiceCore
8
7
  module Base
9
8
  extend ActiveSupport::Concern
10
9
 
10
+ RESERVED_FIELD_NAMES = Set[:call, :errors, :fields, :output, :perform, :response].freeze
11
+
11
12
  included do
13
+ # NOTE: Responder is included first so its initialize (inherited
14
+ # from Output) runs at the bottom of the super chain.
15
+ include ServiceCore::Responder
12
16
  include ActiveModel::Model
13
17
  include ActiveModel::Attributes
14
18
  include ActiveModel::Validations
15
- include ServiceCore::Response
16
-
17
- # NOTE: output attribute will hold output of the service
18
- attr_reader :output
19
19
 
20
- # NOTE: fields attribute will hold the fields defined and their values
20
+ # NOTE: fields holds a ServiceCore::FieldSet snapshot of declared
21
+ # fields and their values at initialize time.
21
22
  attr_reader :fields
22
23
 
23
24
  class << self
24
- # Wrapper method to define attribuutes and attr_accessor methods on object
25
- def fields_defined
26
- @fields_defined ||= {}
25
+ def field_names
26
+ @field_names ||= Set.new
27
27
  end
28
28
 
29
29
  def field(name, *args, **opts)
30
30
  # field :active, :boolean, default: true
31
31
  # field :active, type: :boolean, default: true
32
- # Both explicit and implicit definitions are handled
33
- type = args[0] || opts[:type]
34
- default = args[1] || opts[:default]
32
+ # field :active, :boolean, false # positional default
33
+ # field :payload # untyped (hash/array/object)
34
+ ensure_field_name_available!(name)
35
+ type = args.first || opts[:type]
36
+ # NOTE: arity check, not `||`, so a positional default of
37
+ # `false` or `nil` is not silently swallowed by opts[:default].
38
+ default = args.length >= 2 ? args[1] : opts[:default]
35
39
 
36
- # NOTE: -
37
- # ActiveModel::Attributes support only basic data types
38
- # for ActiveRecord objects we use attr_accessor through ActiveModel::Model
39
- # define attr_accessor to make instance variables also available for attributes
40
-
41
- # define attribute if type is available to type cast
42
40
  if type
43
41
  attribute(name, type, default: default)
44
42
  else
45
43
  attr_accessor(name)
46
44
  end
47
45
 
48
- # save fields defind as an hash, makes it easier to check
49
- fields_defined[name] = default
46
+ field_names.add(name)
50
47
  end
51
48
 
52
49
  def call(attributes = {})
53
- new(attributes).call
50
+ obj = new(attributes)
51
+ obj.call
52
+ obj
53
+ end
54
+
55
+ private
56
+
57
+ def ensure_field_name_available!(name)
58
+ return unless ServiceCore::Base::RESERVED_FIELD_NAMES.include?(name)
59
+
60
+ raise(
61
+ ServiceCore::ReservedFieldName,
62
+ "`#{name}` is reserved by ServiceCore and cannot be used as a field name"
63
+ )
54
64
  end
55
65
  end
56
66
 
57
67
  def initialize(attributes = {})
58
68
  super
59
69
  @local_errors = {}
60
- @fields = {}
61
- # NOTE: this helps identify values passed from values updated
62
- self.class.fields_defined.each_key do |name|
63
- @fields[name] = send(name)
64
- end
65
-
66
- # default value of output
67
- @output = { status: "initialized" }
70
+ snapshot = self.class.field_names.to_h { |name| [name, send(name)] }
71
+ @fields = ServiceCore::FieldSet.new(snapshot)
68
72
  end
69
73
 
70
74
  def call
@@ -74,15 +78,15 @@ module ServiceCore
74
78
  # perform the operation
75
79
  perform
76
80
 
81
+ # auto assign status if output is dirty
82
+ auto_assign_status
83
+
77
84
  # return output
78
85
  output
79
86
  end
80
87
 
81
88
  private
82
89
 
83
- # output writer is protected to be used inside class and sub-classes only
84
- attr_writer :output
85
-
86
90
  def perform
87
91
  raise StandardError, "perform method not implemented"
88
92
  end
@@ -0,0 +1,11 @@
1
+ module ServiceCore
2
+ class Error < StandardError; end
3
+
4
+ # Raised when a value object access (Response#[], Response#[]=) targets
5
+ # a key that is not one of the four allowed response keys.
6
+ class InvalidKey < Error; end
7
+
8
+ # Raised by `field` when a declared name would shadow a method that
9
+ # the gem itself defines on every service.
10
+ class ReservedFieldName < Error; end
11
+ end
@@ -0,0 +1,21 @@
1
+ module ServiceCore
2
+ # Immutable snapshot of a service's declared fields and their values
3
+ # at #initialize time. Each symbol key is exposed as a real method;
4
+ # call #to_h for the raw snapshot Hash.
5
+ class FieldSet
6
+ attr_reader :to_h
7
+
8
+ def initialize(values = {})
9
+ @to_h = values.to_h.freeze
10
+ @to_h.each_key do |name|
11
+ next unless name.is_a?(Symbol)
12
+
13
+ define_singleton_method(name) { @to_h[name] }
14
+ end
15
+ end
16
+
17
+ def inspect
18
+ "#<#{self.class.name} #{@to_h.inspect}>"
19
+ end
20
+ end
21
+ end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "active_support/concern"
4
2
 
5
3
  module ServiceCore
@@ -0,0 +1,45 @@
1
+ require "active_support/concern"
2
+ require_relative "response"
3
+
4
+ module ServiceCore
5
+ module Output
6
+ extend ActiveSupport::Concern
7
+
8
+ ALLOWED_KEYS = ServiceCore::Response::ALLOWED_KEYS
9
+
10
+ # NOTE: output holds the ServiceCore::Response for the service.
11
+ # response is the preferred name; output is retained as an alias.
12
+ attr_reader :output
13
+ alias response output
14
+
15
+ def initialize(_attributes = {})
16
+ # NOTE: super() with empty parens so positional args aren't forwarded
17
+ # up to other modules in the ancestor chain (ActiveModel::Model et al).
18
+ super()
19
+ @output_dirty = false
20
+ @status_dirty = false
21
+ @output = ServiceCore::Response.new
22
+ end
23
+
24
+ private
25
+
26
+ # NOTE: nil values are skipped; legitimate falsy values
27
+ # (false, 0, "") are recorded faithfully.
28
+ def set_output(key, value)
29
+ return if key.nil? || value.nil?
30
+
31
+ @output[key] = value
32
+ @output_dirty = true
33
+ @status_dirty = true if key == :status
34
+ end
35
+
36
+ def auto_assign_status
37
+ if !@output_dirty
38
+ set_output(:status, "success")
39
+ elsif !@status_dirty
40
+ status_value = output[:errors].blank? ? "success" : "error"
41
+ set_output(:status, status_value)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,38 @@
1
+ require "active_support/concern"
2
+ require "active_model/errors"
3
+ require_relative "output"
4
+
5
+ module ServiceCore
6
+ # Mixin that provides the response-building helpers used inside +perform+.
7
+ module Responder
8
+ extend ActiveSupport::Concern
9
+
10
+ include ServiceCore::Output
11
+
12
+ protected
13
+
14
+ def success_response(message: nil, data: nil)
15
+ formatted_response(status: "success", message: message, data: data)
16
+ end
17
+
18
+ def error_response(message:, errors: nil)
19
+ formatted_response(status: "error", message: message, errors: errors)
20
+ end
21
+
22
+ # NOTE: nil-valued keys are skipped; legitimate falsy values
23
+ # (false, 0, "") are recorded faithfully.
24
+ def formatted_response(status:, message: nil, data: nil, errors: nil)
25
+ set_output(:status, status)
26
+ set_output(:message, message)
27
+ set_output(:data, data)
28
+ set_output(:errors, error_messages(errors))
29
+ output
30
+ end
31
+
32
+ def error_messages(errors)
33
+ return nil if errors.nil?
34
+
35
+ errors.is_a?(ActiveModel::Errors) ? errors.messages : errors
36
+ end
37
+ end
38
+ end
@@ -1,33 +1,109 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/concern"
4
- # require "active_model/errors"
1
+ require "active_support/json"
5
2
 
6
3
  module ServiceCore
7
- module Response
8
- extend ActiveSupport::Concern
4
+ # Value object carrying the four-key service response.
5
+ class Response
6
+ ALLOWED_KEYS = %i[status data message errors].freeze
7
+ FETCH_DEFAULT_OMITTED = Object.new.freeze
8
+ private_constant :FETCH_DEFAULT_OMITTED
9
+
10
+ attr_accessor(*ALLOWED_KEYS)
11
+
12
+ def initialize(status: "initialized", data: nil, message: nil, errors: nil)
13
+ @status = status
14
+ @data = data
15
+ @message = message
16
+ @errors = errors
17
+ end
18
+
19
+ # NOTE: writes police the four-key contract; reads (fetch, dig)
20
+ # follow Hash semantics so callers can treat Response like a Hash.
21
+ def [](key)
22
+ ensure_allowed_key!(key)
23
+ public_send(key)
24
+ end
25
+
26
+ def []=(key, value)
27
+ ensure_allowed_key!(key)
28
+ public_send(:"#{key}=", value)
29
+ end
9
30
 
10
- protected
31
+ def fetch(key, default = FETCH_DEFAULT_OMITTED, &block)
32
+ return public_send(key) if key?(key)
33
+ return block.call(key) if block
34
+ return default unless default.equal?(FETCH_DEFAULT_OMITTED)
11
35
 
12
- def success_response(message: nil, data: nil)
13
- formatted_response(status: "success", message: message, data: data)
36
+ raise(KeyError, "key not found: #{key.inspect}")
14
37
  end
15
38
 
16
- def error_response(message:, errors: nil)
17
- formatted_response(status: "error", message: message, errors: errors)
39
+ def key?(key)
40
+ ALLOWED_KEYS.include?(key) && !public_send(key).nil?
18
41
  end
42
+ alias has_key? key?
43
+ alias include? key?
19
44
 
20
- # set output response
21
- def formatted_response(status:, message: nil, data: nil, errors: nil)
22
- @output[:status] = status
23
- @output[:message] = message if message.present?
24
- @output[:data] = data if data.present?
25
- @output[:errors] = error_messages(errors) if errors.present?
26
- @output
45
+ def keys
46
+ ALLOWED_KEYS.select { |key| key?(key) }
27
47
  end
28
48
 
29
- def error_messages(errors)
30
- errors.is_a?(ActiveModel::Errors) ? errors.messages : errors
49
+ def values
50
+ keys.map { |key| public_send(key) }
51
+ end
52
+
53
+ def each_pair
54
+ return to_enum(:each_pair) unless block_given?
55
+
56
+ keys.each { |key| yield(key, public_send(key)) }
57
+ end
58
+ alias each each_pair
59
+
60
+ def to_h
61
+ ALLOWED_KEYS.each_with_object({}) do |key, hash|
62
+ value = public_send(key)
63
+ hash[key] = value unless value.nil?
64
+ end
65
+ end
66
+ alias to_hash to_h
67
+
68
+ def to_s
69
+ to_h.to_s
70
+ end
71
+
72
+ def inspect
73
+ "#<#{self.class.name} #{to_h.inspect}>"
74
+ end
75
+
76
+ def ==(other)
77
+ case other
78
+ when Response then to_h == other.to_h
79
+ when Hash then to_h == other
80
+ else super
81
+ end
82
+ end
83
+
84
+ def dig(key, *rest)
85
+ return nil unless ALLOWED_KEYS.include?(key)
86
+
87
+ value = public_send(key)
88
+ return value if rest.empty? || value.nil?
89
+
90
+ value.respond_to?(:dig) ? value.dig(*rest) : nil
91
+ end
92
+
93
+ def as_json(options = nil)
94
+ to_h.as_json(options)
95
+ end
96
+
97
+ def to_json(*args)
98
+ to_h.to_json(*args)
99
+ end
100
+
101
+ private
102
+
103
+ def ensure_allowed_key!(key)
104
+ return if ALLOWED_KEYS.include?(key)
105
+
106
+ raise(ServiceCore::InvalidKey, "Invalid key. Allowed keys are: #{ALLOWED_KEYS.join(", ")}")
31
107
  end
32
108
  end
33
109
  end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "active_support/concern"
4
2
 
5
3
  module ServiceCore
@@ -7,39 +5,37 @@ module ServiceCore
7
5
  extend ActiveSupport::Concern
8
6
 
9
7
  included do
10
- # NOTE: #
11
- # added this to support partial validation
12
- # valid? method empties all errors, so needed a way to hold errors from within method invocation
8
+ # NOTE: ActiveModel::Validations resets `errors` on every `valid?`,
9
+ # so we re-apply any errors recorded via #add_error through a
10
+ # registered validator. This lets a service accumulate errors
11
+ # mid-perform without them being wiped.
13
12
  validate :local_errors_validation
13
+ end
14
14
 
15
- def local_errors_validation
16
- _local_errors_validation(@local_errors)
17
- end
15
+ def add_error(attribute, message, options = {})
16
+ value = options.empty? ? message : [message, options]
17
+ @local_errors[attribute] ||= []
18
+ @local_errors[attribute] << value
19
+ end
18
20
 
19
- def _local_errors_validation(errors_hsh)
20
- errors_hsh.each do |attribute, messages|
21
- next unless messages
21
+ def add_error_and_validate(attribute, message, options = {})
22
+ add_error(attribute, message, options)
23
+ valid?
24
+ end
22
25
 
23
- messages.each do |message|
24
- errors.add(attribute, *message.is_a?(Array) ? message : [message])
25
- end
26
- end
27
- {}
28
- end
26
+ private
29
27
 
30
- def add_error(attribute, message, options = {})
31
- value = options.present? ? [message, options] : message
32
- @local_errors[attribute] = if @local_errors[attribute].present?
33
- Array(@local_errors[attribute]) << value
34
- else
35
- [value]
36
- end
28
+ def local_errors_validation
29
+ @local_errors.each do |attribute, messages|
30
+ Array(messages).each { |message| apply_local_error(attribute, message) }
37
31
  end
32
+ end
38
33
 
39
- # method to add partial errors and validate
40
- def add_error_and_validate(attribute, message, _options = {})
41
- add_error(attribute, message, {})
42
- valid?
34
+ def apply_local_error(attribute, message)
35
+ if message.is_a?(Array)
36
+ errors.add(attribute, *message)
37
+ else
38
+ errors.add(attribute, message)
43
39
  end
44
40
  end
45
41
  end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module ServiceCore
4
- VERSION = "0.1.3"
2
+ VERSION = "1.0.0".freeze
5
3
  end
data/lib/service_core.rb CHANGED
@@ -1,6 +1,7 @@
1
- # frozen_string_literal: true
2
-
3
1
  require_relative "service_core/version"
2
+ require_relative "service_core/errors"
3
+ require_relative "service_core/response"
4
+ require_relative "service_core/field_set"
4
5
  require_relative "service_core/base"
5
6
  require_relative "service_core/step_validation"
6
7
  require_relative "service_core/logger"
@@ -17,8 +18,6 @@ module ServiceCore
17
18
  include ServiceCore::Logger
18
19
  end
19
20
 
20
- class Error < StandardError; end
21
-
22
21
  class << self
23
22
  attr_writer :logger
24
23
 
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: service_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mayank sehgal
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-07-17 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activemodel
@@ -19,7 +18,7 @@ dependencies:
19
18
  version: '6.1'
20
19
  - - "<"
21
20
  - !ruby/object:Gem::Version
22
- version: '8.0'
21
+ version: '9.0'
23
22
  type: :runtime
24
23
  prerelease: false
25
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -29,7 +28,7 @@ dependencies:
29
28
  version: '6.1'
30
29
  - - "<"
31
30
  - !ruby/object:Gem::Version
32
- version: '8.0'
31
+ version: '9.0'
33
32
  - !ruby/object:Gem::Dependency
34
33
  name: activesupport
35
34
  requirement: !ruby/object:Gem::Requirement
@@ -39,7 +38,7 @@ dependencies:
39
38
  version: '6.1'
40
39
  - - "<"
41
40
  - !ruby/object:Gem::Version
42
- version: '8.0'
41
+ version: '9.0'
43
42
  type: :runtime
44
43
  prerelease: false
45
44
  version_requirements: !ruby/object:Gem::Requirement
@@ -49,35 +48,43 @@ dependencies:
49
48
  version: '6.1'
50
49
  - - "<"
51
50
  - !ruby/object:Gem::Version
52
- version: '8.0'
53
- description: A service pattern implementation for Rails applications.
51
+ version: '9.0'
52
+ description: ServiceCore provides a four-key response contract (status, data, message,
53
+ errors) for service objects, with field declarations, validations, step validation,
54
+ structured logging, and a Hash-compatible Response value object.
54
55
  email:
55
56
  - sehgalmayank001@gmail.com
56
57
  executables: []
57
58
  extensions: []
58
59
  extra_rdoc_files: []
59
60
  files:
60
- - ".byebug_history"
61
61
  - ".rspec"
62
62
  - ".rubocop.yml"
63
+ - ".tool-versions"
64
+ - Appraisals
63
65
  - CHANGELOG.md
64
66
  - CODE_OF_CONDUCT.md
65
67
  - LICENSE.txt
66
68
  - README.md
67
69
  - Rakefile
70
+ - gemfiles/rails_7.2.gemfile
71
+ - gemfiles/rails_8.0.gemfile
72
+ - gemfiles/rails_8.1.gemfile
68
73
  - lib/service_core.rb
69
74
  - lib/service_core/base.rb
75
+ - lib/service_core/errors.rb
76
+ - lib/service_core/field_set.rb
70
77
  - lib/service_core/logger.rb
78
+ - lib/service_core/output.rb
79
+ - lib/service_core/responder.rb
71
80
  - lib/service_core/response.rb
72
81
  - lib/service_core/step_validation.rb
73
82
  - lib/service_core/version.rb
74
- - service_core.gemspec
75
- - sig/service_core.rbs
76
83
  homepage: https://github.com/sehgalmayank001/service-core
77
84
  licenses:
78
85
  - MIT
79
- metadata: {}
80
- post_install_message:
86
+ metadata:
87
+ rubygems_mfa_required: 'true'
81
88
  rdoc_options: []
82
89
  require_paths:
83
90
  - lib
@@ -85,15 +92,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
85
92
  requirements:
86
93
  - - ">="
87
94
  - !ruby/object:Gem::Version
88
- version: 2.6.0
95
+ version: 3.1.0
89
96
  required_rubygems_version: !ruby/object:Gem::Requirement
90
97
  requirements:
91
98
  - - ">="
92
99
  - !ruby/object:Gem::Version
93
100
  version: '0'
94
101
  requirements: []
95
- rubygems_version: 3.4.22
96
- signing_key:
102
+ rubygems_version: 3.6.9
97
103
  specification_version: 4
98
- summary: A Rails service pattern implementation
104
+ summary: A standardised service object pattern for Ruby and Rails
99
105
  test_files: []
data/.byebug_history DELETED
@@ -1,71 +0,0 @@
1
- c
2
- @logger
3
- c
4
- defined?(Rails) && Rails.logger ? Rails.logger : ActiveSupport::Logger.new($stdout)
5
- c
6
- defined?(Rails) && Rails.logger ? Rails.logger : ActiveSupport::Logger.new($stdout)
7
- defined?(Rails)
8
- c
9
- defined?(Rails)
10
- c
11
- ServiceCore.logger
12
- c
13
- @logger
14
- c
15
- @logger
16
- c
17
- self.active
18
- self.status
19
- c
20
- self.active
21
- name
22
- c
23
- name
24
- n
25
- self.class.fields_defined
26
- @fields
27
- c
28
- n
29
- default
30
- self
31
- type
32
- n
33
- default
34
- n
35
- args
36
- opts
37
- c
38
- args
39
- opts
40
- c
41
- service
42
- c
43
- service
44
- c
45
- service
46
- c
47
- self.name
48
- n
49
- attributes
50
- c
51
- service
52
- c
53
- service.fields
54
- service
55
- c
56
- service.name
57
- c
58
- send(name)
59
- @fields
60
- name
61
- c
62
- @fields
63
- n
64
- c
65
- service
66
- c
67
- service.name
68
- service
69
- c
70
- service.fields
71
- service