service_core 0.2.0 → 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,51 +1,49 @@
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
12
- # ServiceCore::Response is included first as it inherited output,
13
- # which too has initialize method.
14
- include ServiceCore::Response
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
15
16
  include ActiveModel::Model
16
17
  include ActiveModel::Attributes
17
18
  include ActiveModel::Validations
18
19
 
19
- # 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.
20
22
  attr_reader :fields
21
23
 
22
24
  class << self
23
- # Wrapper method to define attribuutes and attr_accessor methods on object
24
- def fields_defined
25
- @fields_defined ||= {}
25
+ def field_names
26
+ @field_names ||= Set.new
26
27
  end
27
28
 
28
29
  def field(name, *args, **opts)
29
30
  # field :active, :boolean, default: true
30
31
  # field :active, type: :boolean, default: true
31
- # Both explicit and implicit definitions are handled
32
- type = args[0] || opts[:type]
33
- 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]
34
39
 
35
- # NOTE: -
36
- # ActiveModel::Attributes support only basic data types
37
- # for ActiveRecord objects we use attr_accessor through ActiveModel::Model
38
- # define attr_accessor to make instance variables also available for attributes
39
-
40
- # define attribute if type is available to type cast
41
40
  if type
42
41
  attribute(name, type, default: default)
43
42
  else
44
43
  attr_accessor(name)
45
44
  end
46
45
 
47
- # save fields defind as an hash, makes it easier to check
48
- fields_defined[name] = default
46
+ field_names.add(name)
49
47
  end
50
48
 
51
49
  def call(attributes = {})
@@ -53,16 +51,24 @@ module ServiceCore
53
51
  obj.call
54
52
  obj
55
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
+ )
64
+ end
56
65
  end
57
66
 
58
67
  def initialize(attributes = {})
59
68
  super
60
69
  @local_errors = {}
61
- @fields = {}
62
- # NOTE: this helps identify values passed from values updated
63
- self.class.fields_defined.each_key do |name|
64
- @fields[name] = send(name)
65
- end
70
+ snapshot = self.class.field_names.to_h { |name| [name, send(name)] }
71
+ @fields = ServiceCore::FieldSet.new(snapshot)
66
72
  end
67
73
 
68
74
  def call
@@ -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
@@ -1,42 +1,44 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "active_support/concern"
4
- # require "active_model/errors"
2
+ require_relative "response"
5
3
 
6
4
  module ServiceCore
7
5
  module Output
8
6
  extend ActiveSupport::Concern
9
7
 
10
- ALLOWED_KEYS = %i[status data message errors].freeze
8
+ ALLOWED_KEYS = ServiceCore::Response::ALLOWED_KEYS
11
9
 
12
- # NOTE: output attribute will hold output of the service
10
+ # NOTE: output holds the ServiceCore::Response for the service.
11
+ # response is the preferred name; output is retained as an alias.
13
12
  attr_reader :output
13
+ alias response output
14
14
 
15
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()
16
19
  @output_dirty = false
17
20
  @status_dirty = false
18
- @output = { status: "initialized" }
21
+ @output = ServiceCore::Response.new
19
22
  end
20
23
 
21
24
  private
22
25
 
26
+ # NOTE: nil values are skipped; legitimate falsy values
27
+ # (false, 0, "") are recorded faithfully.
23
28
  def set_output(key, value)
24
- return unless key && value
25
- raise ArgumentError, "Invalid key. Allowed keys are: #{ALLOWED_KEYS.join(", ")}" unless ALLOWED_KEYS.include?(key)
29
+ return if key.nil? || value.nil?
26
30
 
27
- @output_dirty ||= true
28
- @status_dirty = true if key == :status && !@status_dirty
29
31
  @output[key] = value
32
+ @output_dirty = true
33
+ @status_dirty = true if key == :status
30
34
  end
31
35
 
32
36
  def auto_assign_status
33
37
  if !@output_dirty
34
38
  set_output(:status, "success")
35
- elsif @output_dirty && !@status_dirty
39
+ elsif !@status_dirty
36
40
  status_value = output[:errors].blank? ? "success" : "error"
37
41
  set_output(:status, status_value)
38
- elsif @output_dirty && @status_dirty
39
- # ignore
40
42
  end
41
43
  end
42
44
  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,36 +1,109 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/concern"
4
- require_relative "output"
5
- # require "active_model/errors"
1
+ require "active_support/json"
6
2
 
7
3
  module ServiceCore
8
- module Response
9
- 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
30
+
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)
35
+
36
+ raise(KeyError, "key not found: #{key.inspect}")
37
+ end
38
+
39
+ def key?(key)
40
+ ALLOWED_KEYS.include?(key) && !public_send(key).nil?
41
+ end
42
+ alias has_key? key?
43
+ alias include? key?
44
+
45
+ def keys
46
+ ALLOWED_KEYS.select { |key| key?(key) }
47
+ end
10
48
 
11
- include ServiceCore::Output
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
12
59
 
13
- protected
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
14
67
 
15
- def success_response(message: nil, data: nil)
16
- formatted_response(status: "success", message: message, data: data)
68
+ def to_s
69
+ to_h.to_s
17
70
  end
18
71
 
19
- def error_response(message:, errors: nil)
20
- formatted_response(status: "error", message: message, errors: errors)
72
+ def inspect
73
+ "#<#{self.class.name} #{to_h.inspect}>"
21
74
  end
22
75
 
23
- # set output response
24
- def formatted_response(status:, message: nil, data: nil, errors: nil)
25
- set_output(:status, status)
26
- set_output(:message, message) if message.present?
27
- set_output(:data, data) if data.present?
28
- set_output(:errors, error_messages(errors)) if errors.present?
29
- output
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
30
82
  end
31
83
 
32
- def error_messages(errors)
33
- errors.is_a?(ActiveModel::Errors) ? errors.messages : errors
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(", ")}")
34
107
  end
35
108
  end
36
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.2.0"
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.2.0
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-21 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,36 +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
71
78
  - lib/service_core/output.rb
79
+ - lib/service_core/responder.rb
72
80
  - lib/service_core/response.rb
73
81
  - lib/service_core/step_validation.rb
74
82
  - lib/service_core/version.rb
75
- - sig/service_core.rbs
76
83
  homepage: https://github.com/sehgalmayank001/service-core
77
84
  licenses:
78
85
  - MIT
79
86
  metadata:
80
87
  rubygems_mfa_required: 'true'
81
- post_install_message:
82
88
  rdoc_options: []
83
89
  require_paths:
84
90
  - lib
@@ -86,15 +92,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
86
92
  requirements:
87
93
  - - ">="
88
94
  - !ruby/object:Gem::Version
89
- version: 2.7.0
95
+ version: 3.1.0
90
96
  required_rubygems_version: !ruby/object:Gem::Requirement
91
97
  requirements:
92
98
  - - ">="
93
99
  - !ruby/object:Gem::Version
94
100
  version: '0'
95
101
  requirements: []
96
- rubygems_version: 3.4.22
97
- signing_key:
102
+ rubygems_version: 3.6.9
98
103
  specification_version: 4
99
- summary: A Rails service pattern implementation
104
+ summary: A standardised service object pattern for Ruby and Rails
100
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
data/sig/service_core.rbs DELETED
@@ -1,4 +0,0 @@
1
- module ServiceCore
2
- VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
- end