mojones 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0e3e1098a30a3cb361430de99a97ede30f422ed06c4778d20f140bd313c7a3d
4
- data.tar.gz: addc8a5acded5a911a9fd214a1983aeac71258a3d55e66f34e59f2ad7b18afba
3
+ metadata.gz: ab2a33993a4fda3c283c9e61948fa594c550422bfd8faab82037c9603fc2f335
4
+ data.tar.gz: 7df0d3e348b050307dd5c26c8de32d4cb18bf588ec6e1435a4818925c518dae9
5
5
  SHA512:
6
- metadata.gz: 1705669f6ef6e3084bbe785385ebe343a8217825532255e0b48338eff104f6a2d1ba4e77e7dbd70666e43648030626833ffad5b45cc48e2c039a10f04bfdef91
7
- data.tar.gz: 9907b26c7a0af8ccb7233a7f8c6830ae89ac4a806f963c21c41d88a652effa073b505769bacb480ab97448b950911a15b5c85612d77623184681fe375cc00bf0
6
+ metadata.gz: 8e360c4ab6d4c34059cf4b0aa68fe7bc25276a080ef908c6355aab4dfdbf2b3898cd9531009abd1b45c8d7b62920293e84dcc413e8faef2a85b45fd2b797fb03
7
+ data.tar.gz: efb0eac1c498889aa00d2c08a233721e730ed039015535e4ba8ae30af3a9376653dc57fc1a7224efdf48d48c20ddf19b011f1491c1c584fe93c34624f94ea3e6
data/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 (unreleased)
4
+
5
+ ### Breaking
6
+
7
+ - **The first matching handler now wins, and later handlers don't run.**
8
+ Before, every matching handler ran and the last one's result was
9
+ returned. If you put a catch-all before a more specific handler, swap
10
+ them so the specific one comes first:
11
+
12
+ ```ruby
13
+ # 0.1.0
14
+ m.failure { |_| "Something went wrong" }
15
+ m.failure(:card_declined) { |_| "Your card was declined" }
16
+
17
+ # 0.2.0
18
+ m.failure(:card_declined) { |_| "Your card was declined" }
19
+ m.failure { |_| "Something went wrong" }
20
+ ```
21
+
22
+ - **Failure values other than Symbols and exceptions reach the handler
23
+ unchanged.** For example, `Failure(user.errors)` now yields the errors
24
+ object. Before, it became a string: `to_s`, or a "translation missing"
25
+ message when I18n was loaded.
26
+ - **Ruby 3.3 or later is required.** Ruby 3.2 reached end of life in March 2026.
27
+
28
+ ### Fixed
29
+
30
+ - `rails generate mojones:service` works when the gem is installed from
31
+ RubyGems. The template was missing from the packaged gem.
32
+ - Failures from anonymous service or exception classes no longer raise
33
+ `NoMethodError` when I18n is loaded.
34
+ - Apps that load I18n without ActiveSupport no longer raise
35
+ `NoMethodError` from failure handlers. Translation is skipped instead.
36
+ - The matcher no longer writes debug output to stdout outside Rails.
37
+
38
+ ## 0.1.0 (2026-03-21)
39
+
40
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Phil Brockwell, Habib Alamin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # Mojones
2
+
3
+ Service objects with monadic result handling. A lightweight framework
4
+ around [dry-monads](https://dry-rb.org/gems/dry-monads/) that gives every
5
+ service object one enforced shape — `.call` always returns a `Success`/
6
+ `Failure` result (or raises), and a small block-based matcher lets callers
7
+ branch on the outcome without a pile of `if result.success?` checks.
8
+
9
+ ## Installation
10
+
11
+ ```
12
+ bundle add mojones
13
+ ```
14
+
15
+ Or add it to your Gemfile directly:
16
+
17
+ ```ruby
18
+ gem "mojones"
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Inherit from `Mojones::Base` and return a `Dry::Monads::Result` from
24
+ `#call`:
25
+
26
+ ```ruby
27
+ class CreateUser < Mojones::Base
28
+ def initialize(params)
29
+ @params = params
30
+ end
31
+
32
+ def call
33
+ user = User.new(@params)
34
+ return Failure(user.errors) unless user.save
35
+
36
+ Success(user)
37
+ end
38
+ end
39
+ ```
40
+
41
+ Calling `.call` without a block gives you the `Result` itself:
42
+
43
+ ```ruby
44
+ result = CreateUser.call(params) # => Success(#<User ...>) or Failure(#<ActiveModel::Errors ...>)
45
+ result.value! # => #<User ...>, or raises on failure
46
+ ```
47
+
48
+ Pass a block to match on the outcome instead:
49
+
50
+ ```ruby
51
+ CreateUser.call(params) do |m|
52
+ m.success { |user| redirect_to user }
53
+ m.failure { |errors| render :new, status: :unprocessable_entity }
54
+ end
55
+ ```
56
+
57
+ `success` and `failure` can also filter on the value or error, using `===`
58
+ (so classes, ranges, regexps and literal values all work):
59
+
60
+ ```ruby
61
+ notice = ChargeCard.call(order) do |m|
62
+ m.success { |charge| "Charged #{charge.amount}" }
63
+ m.failure(CardDeclined) { |_| "Your card was declined" }
64
+ m.failure { |_error, message| message }
65
+ end
66
+ ```
67
+
68
+ Handlers are tried in order and the **first** match wins, like
69
+ `case`/`when`. Later handlers don't run at all, so put specific handlers
70
+ before catch-alls, and side effects like `redirect_to` only ever happen
71
+ once.
72
+
73
+ If the service raises, the exception is matchable as a failure, so
74
+ `m.failure(ActiveRecord::RecordNotFound) { ... }` works. Without a block,
75
+ or when no handler matches, the exception is re-raised.
76
+
77
+ If `#call` returns something that isn't a `Dry::Monads::Result`, `.call`
78
+ raises `Mojones::Base::Errors::ServiceReturnedNonResult`. That's a bug in
79
+ the service, so it can't be matched.
80
+
81
+ ### Failure values and I18n
82
+
83
+ A one-argument `failure` block gets the failure value translated:
84
+
85
+ - A Symbol is looked up under the service's I18n scope, so
86
+ `Failure(:card_declined)` in `Billing::ChargeCard` is looked up as
87
+ `billing.charge_card.card_declined`. If there's no translation, it falls
88
+ back to `"Card declined"`.
89
+ - An exception is looked up by its class name, falling back to its message.
90
+ - Any other value (for example `user.errors`) is passed through unchanged.
91
+
92
+ Translation needs I18n and ActiveSupport, so in practice it means Rails.
93
+ Without them, Symbols and exceptions arrive as `to_s` strings. Anonymous
94
+ classes are never translated.
95
+
96
+ To get the untranslated value as well, take two arguments:
97
+
98
+ ```ruby
99
+ m.failure { |error, message| flash[:alert] = message }
100
+ ```
101
+
102
+ ### Rails generator
103
+
104
+ ```
105
+ bin/rails generate mojones:service CreateUser
106
+ ```
107
+
108
+ Scaffolds `app/services/create_user.rb` with the `Mojones::Base` skeleton
109
+ above.
110
+
111
+ ## Development
112
+
113
+ ```
114
+ bundle install
115
+ bundle exec rspec # tests
116
+ bundle exec rubocop # lint
117
+ ```
118
+
119
+ CI (`.github/workflows/`) runs both on every push and pull request.
120
+
121
+ ## License
122
+
123
+ MIT — see `LICENSE.txt`.
124
+
125
+ ---
126
+
127
+ Built by [TrueFlux](https://trueflux.agency).
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < Mojones::Base
4
+ def call
5
+ # TODO: implement service logic
6
+ Success(:ok)
7
+ end
8
+ end
data/lib/mojones/base.rb CHANGED
@@ -7,22 +7,13 @@ module Mojones
7
7
  module Errors
8
8
  class NoHandlerMatched < StandardError
9
9
  def initialize(value)
10
- super
11
- @value = value
10
+ super("No handler matched for #{value.inspect}")
12
11
  end
13
-
14
- def message = "No handler matched for #{@value.inspect}"
15
12
  end
16
13
 
17
14
  class ServiceReturnedNonResult < StandardError
18
15
  def initialize(service, value)
19
- super(value)
20
- @service_name = service.class.inspect.sub(/^Mojones::/, "")
21
- @value = value
22
- end
23
-
24
- def message
25
- "Service #{@service_name} returned non-Result value (#{@value.inspect} : #{@value.class})"
16
+ super("Service #{service.name} returned non-Result value (#{value.inspect} : #{value.class})")
26
17
  end
27
18
  end
28
19
  end
@@ -36,10 +27,6 @@ module Mojones
36
27
  @value
37
28
  end
38
29
 
39
- def fmap
40
- yield @value
41
- end
42
-
43
30
  def success?
44
31
  @value.success?
45
32
  end
@@ -57,30 +44,6 @@ module Mojones
57
44
  end
58
45
  end
59
46
 
60
- class InitializerReturnedValue < ReturnedValue
61
- def success? = true
62
- def failure? = true
63
- def original = @value
64
-
65
- def assert_call_returned_result_monad_or_raised!
66
- self
67
- end
68
- end
69
-
70
- class CallReturnedValue < ReturnedValue
71
- def initialize(service, value)
72
- super(value)
73
- @service = service
74
- @value = value
75
- end
76
-
77
- def assert_call_returned_result_monad_or_raised!
78
- return self if @value.is_a?(Dry::Monads::Result)
79
-
80
- raise Errors::ServiceReturnedNonResult.new(@service, @value)
81
- end
82
- end
83
-
84
47
  class RaisedError
85
48
  def initialize(error)
86
49
  @error = error
@@ -97,35 +60,30 @@ module Mojones
97
60
  @error
98
61
  end
99
62
 
100
- def fmap
101
- self
102
- end
103
-
104
63
  def no_matches!
105
64
  raise @error
106
65
  end
107
-
108
- def assert_call_returned_result_monad_or_raised!
109
- self
110
- end
111
66
  end
112
67
 
113
- def self.call(*args, **kwargs, &)
114
- result = (
115
- begin
116
- InitializerReturnedValue.new(new(*args, **kwargs))
117
- rescue StandardError => e
118
- RaisedError.new(e)
119
- end
120
- ).fmap do |service_object|
121
- CallReturnedValue.new(service_object, service_object.call)
122
- rescue StandardError => e
123
- RaisedError.new(e)
124
- end.assert_call_returned_result_monad_or_raised!
125
-
68
+ def self.call(*, **, &)
69
+ result = execute(*, **)
126
70
  return result.value! unless block_given?
127
71
 
128
72
  Matcher.new(result, self, &).result
129
73
  end
74
+
75
+ # Errors raised by the service become a matchable RaisedError. Returning a
76
+ # non-Result is a bug in the service, so it raises instead (an exception
77
+ # raised in `else` isn't caught by the method's `rescue`).
78
+ def self.execute(*, **)
79
+ value = new(*, **).call
80
+ rescue StandardError => e
81
+ RaisedError.new(e)
82
+ else
83
+ raise Errors::ServiceReturnedNonResult.new(self, value) unless value.is_a?(Dry::Monads::Result)
84
+
85
+ ReturnedValue.new(value)
86
+ end
87
+ private_class_method :execute
130
88
  end
131
89
  end
@@ -1,98 +1,88 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "logger"
4
-
5
3
  module Mojones
6
4
  class Matcher
7
- class Match
8
- attr_reader :matched, :result
9
-
10
- def initialize(matched:, result:)
11
- @matched = matched
12
- @result = result
13
- end
14
-
15
- class Success < self; end
16
- class Failure < self; end
17
- end
18
-
19
5
  def initialize(service_result, service)
20
6
  @service = service
21
7
  @service_result = service_result
22
- @matches = []
8
+ @matched = false
23
9
  yield(self) if block_given?
24
10
  end
25
11
 
26
12
  def result
27
- return @service_result.no_matches! if @matches.empty?
28
-
29
- @matches.last.result.tap do |chosen|
30
- if @matches.size > 1
31
- logger.debug <<~WARNING
32
- #{service.name} matched multiple handlers; returning last result (#{chosen})
33
- WARNING
34
- end
35
- end
13
+ return @service_result.no_matches! unless @matched
14
+
15
+ @result
36
16
  end
37
17
 
38
- def success(*match_values, &block)
39
- return unless @service_result.success?
18
+ # Handlers are tried in order and the first match wins, like case/when.
19
+ # Later handlers are skipped entirely, so their side effects never run.
20
+ def success(*patterns)
21
+ return if @matched || !@service_result.success?
40
22
 
41
23
  value = @service_result.original
24
+ return unless matches?(patterns, value)
42
25
 
43
- return unless match_values.empty? || match_values.any? { _1 === value } # rubocop:disable Style/CaseEquality
44
-
45
- @matches << Match::Success.new(
46
- matched: value,
47
- result: block.call(value)
48
- )
26
+ match(yield(value))
49
27
  end
50
28
 
51
- def failure(*match_errors, &block)
52
- return unless @service_result.failure?
29
+ def failure(*patterns, &block)
30
+ return if @matched || !@service_result.failure?
53
31
 
54
32
  error = @service_result.original
55
-
56
- return unless match_errors.empty? || match_errors.any? { _1 === error } # rubocop:disable Style/CaseEquality
33
+ return unless matches?(patterns, error)
57
34
 
58
35
  translated = translate_error(error)
59
-
60
- @matches << Match::Failure.new(
61
- matched: error,
62
- result: block.arity == 2 ? block.call(error, translated) : block.call(translated)
63
- )
36
+ match(block.arity == 2 ? yield(error, translated) : yield(translated))
64
37
  end
65
38
 
66
39
  private
67
40
 
68
41
  attr_reader :service
69
42
 
70
- def logger
71
- @logger ||= defined?(Rails) ? Rails.logger : Logger.new($stdout)
43
+ def match(result)
44
+ @matched = true
45
+ @result = result
72
46
  end
73
47
 
74
- def service_name_lookup
75
- @service_name_lookup ||= service.name.gsub("::", ".").underscore
48
+ # Matches like case/when, so classes, ranges, regexps and plain values all work.
49
+ def matches?(patterns, value)
50
+ return true if patterns.empty?
51
+
52
+ case value
53
+ when *patterns then true
54
+ else false
55
+ end
76
56
  end
77
57
 
78
58
  def translate_error(error)
79
- return error.to_s unless defined?(I18n)
80
-
81
- key =
82
- case error
83
- when Symbol
84
- error
85
- when StandardError
86
- error.class.name.gsub("::", ".").underscore
87
- end
88
-
89
- default =
90
- case error
91
- when Symbol then error.to_s.humanize
92
- when StandardError then error.message
93
- end
94
-
95
- I18n.t("#{service_name_lookup}.#{key}", default: default)
59
+ return error unless error.is_a?(Symbol) || error.is_a?(StandardError)
60
+ return error.to_s unless translatable?(error)
61
+
62
+ I18n.t(error_key(error), default: error_default(error))
63
+ end
64
+
65
+ # Keys are built from class names, so anonymous classes can't be translated.
66
+ def translatable?(error)
67
+ i18n_available? && service.name && (error.is_a?(Symbol) || error.class.name)
68
+ end
69
+
70
+ # Key building and defaults use ActiveSupport's inflector.
71
+ def i18n_available?
72
+ defined?(I18n) && defined?(ActiveSupport::Inflector)
73
+ end
74
+
75
+ def error_key(error)
76
+ key = error.is_a?(Symbol) ? error : i18n_path(error.class.name)
77
+ "#{i18n_path(service.name)}.#{key}"
78
+ end
79
+
80
+ def error_default(error)
81
+ error.is_a?(Symbol) ? ActiveSupport::Inflector.humanize(error) : error.message
82
+ end
83
+
84
+ def i18n_path(name)
85
+ ActiveSupport::Inflector.underscore(name.gsub("::", "."))
96
86
  end
97
87
  end
98
88
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mojones
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mojones
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Phil Brockwell
@@ -33,15 +33,20 @@ executables: []
33
33
  extensions: []
34
34
  extra_rdoc_files: []
35
35
  files:
36
+ - CHANGELOG.md
37
+ - LICENSE.txt
38
+ - README.md
36
39
  - lib/generators/mojones/service_generator.rb
40
+ - lib/generators/mojones/templates/service.rb.tt
37
41
  - lib/mojones.rb
38
42
  - lib/mojones/base.rb
39
43
  - lib/mojones/matcher.rb
40
44
  - lib/mojones/version.rb
41
- homepage: https://trueflux.agency
45
+ homepage: https://github.com/TrueFlux/mojones
42
46
  licenses:
43
47
  - MIT
44
48
  metadata:
49
+ changelog_uri: https://github.com/TrueFlux/mojones/blob/main/CHANGELOG.md
45
50
  rubygems_mfa_required: 'true'
46
51
  rdoc_options: []
47
52
  require_paths:
@@ -50,14 +55,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
50
55
  requirements:
51
56
  - - ">="
52
57
  - !ruby/object:Gem::Version
53
- version: '3.2'
58
+ version: '3.3'
54
59
  required_rubygems_version: !ruby/object:Gem::Requirement
55
60
  requirements:
56
61
  - - ">="
57
62
  - !ruby/object:Gem::Version
58
63
  version: '0'
59
64
  requirements: []
60
- rubygems_version: 3.6.7
65
+ rubygems_version: 4.0.3
61
66
  specification_version: 4
62
67
  summary: Service objects with monadic result handling
63
68
  test_files: []