dry-monads 1.10.0 → 1.11.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: 527c622566bcbf2acd219b357314a4b6388f49ef9f4dcfd775298de921629c7b
4
- data.tar.gz: 3dc196066bd360620f39a550575a52b4d112849e09484ad76eb565002611950c
3
+ metadata.gz: 74c983b4b86f5bc73455c3f8987760ae5e11d026f87fbcb4741da18051c60e10
4
+ data.tar.gz: 42b72b1ab09dab98fe8c5a399d17d882a2fdcdf45971ec70e29868fbb07dfcba
5
5
  SHA512:
6
- metadata.gz: 5dc4be9b41c1f2349b8078831d0d8980e89095da3b09cbf0084d392ce0966fcf4575d4ffa811deecf894eeb4c16cea1d09d97ebd2ef6458c6e18864f0adc42b6
7
- data.tar.gz: 587c40965ce23128508b1978d2e5e2d55aff7d3780f777d6a45fbff58d19165809d8a143871269b6092ef475b4b769fcfa262414693bd9b716435ec45c4b0915
6
+ metadata.gz: d7afa3e95783b08fb767e1ad0b4be5aa5e3e3bf096e534884783a419acc62ccbc141fbdf641a673b9a2808b6bf27fdfb2ca64dc7e0360adbaff7f587353b3357
7
+ data.tar.gz: 831f701ceb7e787bab8d4c77f159aeaa1a13c5c387c790e2c50fe78a8c50a9d9d0610893466eb3f06ae73b9cbd31c08fcf45f0a8dc6822d203949abafce23870
data/CHANGELOG.md CHANGED
@@ -21,6 +21,52 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve
21
21
 
22
22
  [Unreleased]: https://github.com/dry-rb/dry-monads/compare/v1.10.0...main
23
23
 
24
+ ## [1.11.0] - 2026-09-11
25
+
26
+ ### Added
27
+
28
+ - New `:json` extension, which builds a `JSON::Coder` that reads and writes monads. It needs the json gem 2.15.0 or later. (@timriley in #209)
29
+
30
+ ```ruby
31
+ Dry::Monads.load_extensions(:json)
32
+
33
+ coder = Dry::Monads.json_coder
34
+ coder.dump(Some(3)) # => %({"json_class":"Dry::Monads::Maybe::Some","value":3})
35
+ coder.load(%({"json_class":"Dry::Monads::Maybe::Some","value":3})) # => Some(3)
36
+ ```
37
+
38
+ Give `json_coder` an `as_json:` and an `on_load:` callback to handle your own types in the same coder. Both run after the monad callbacks, and your types and monads can nest inside each other:
39
+
40
+ ```ruby
41
+ coder = Dry::Monads.json_coder(
42
+ as_json: ->(object, *) {
43
+ object.is_a?(Time) ? {"json_class" => "Time", "value" => object.iso8601} : object
44
+ },
45
+ on_load: ->(value) {
46
+ value.is_a?(Hash) && value["json_class"] == "Time" ? Time.iso8601(value["value"]) : value
47
+ }
48
+ )
49
+ ```
50
+
51
+ ### Removed
52
+
53
+ - **Breaking**: removed `json/add/dry/monads/maybe`. The json gem 3.0 removed the whole `json/add` mechanism, so monads can no longer hook into `JSON.dump` and `JSON.load` globally. Use the new `:json` extension instead. (@timriley in #209)
54
+
55
+ ```ruby
56
+ # before
57
+ require "json/add/dry/monads/maybe"
58
+ JSON.unsafe_load(JSON.dump(data))
59
+
60
+ # after
61
+ Dry::Monads.load_extensions(:json)
62
+ coder = Dry::Monads.json_coder
63
+ coder.load(coder.dump(data))
64
+ ```
65
+
66
+ The JSON serialization format is the same as before, so JSON written by the old serializer still loads. One behavior differs: `JSON::Coder` will always raise a `JSON::GeneratorError` when it sees an object with no JSON counterpart raises, instead of falling back to `to_s`.
67
+
68
+ [1.11.0]: https://github.com/dry-rb/dry-monads/compare/v1.10.0...v1.11.0
69
+
24
70
  ## [1.10.0] - 2026-04-24
25
71
 
26
72
  ### Added
data/dry-monads.gemspec CHANGED
@@ -28,7 +28,7 @@ Gem::Specification.new do |spec|
28
28
  spec.metadata["bug_tracker_uri"] = "https://github.com/dry-rb/dry-monads/issues"
29
29
  spec.metadata["funding_uri"] = "https://github.com/sponsors/hanami"
30
30
 
31
- spec.required_ruby_version = ">= 3.1.0"
31
+ spec.required_ruby_version = ">= 3.3"
32
32
 
33
33
  spec.add_runtime_dependency "concurrent-ruby", "~> 1.0"
34
34
  spec.add_runtime_dependency "dry-core", "~> 1.1"
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ # Require json 2.15, the first version with everything this extension needs:
6
+ #
7
+ # - json 2.10 has `JSON::Coder`, but ignores the `on_load:` option
8
+ # - json 2.11 through 2.14 never pass the hash key flag to the `as_json` callback
9
+ #
10
+ # Both of these gaps are silent and may lead to incorrect serialization, so refuse to load instead.
11
+ if Gem::Version.new(::JSON::VERSION) < Gem::Version.new("2.15.0")
12
+ raise "The Dry Monads :json extension needs json 2.15.0 or later, but json #{::JSON::VERSION} is loaded"
13
+ end
14
+
15
+ module Dry
16
+ module Monads
17
+ # Reads and writes monads as JSON via `JSON::Coder`.
18
+ #
19
+ # This replaces the old `json/add/dry/monads/maybe` file, no longer working from json 3.0, which
20
+ # removed the whole `json/add` mechanism, so monads can no longer serialize via `JSON.dump` and
21
+ # `JSON.load` globally. A coder does the same job locally, without patching anything.
22
+ #
23
+ # The wire format remains unchanged, so JSON written by older versions still loads.
24
+ #
25
+ # @example
26
+ # Dry::Monads.load_extensions(:json)
27
+ #
28
+ # coder = Dry::Monads.json_coder
29
+ # coder.dump(Some(3)) # => %({"json_class":"Dry::Monads::Maybe::Some","value":3})
30
+ # coder.load(%({"json_class":"Dry::Monads::Maybe::Some","value":3})) # => Some(3)
31
+ #
32
+ # @api public
33
+ module JSONCoder
34
+ # Key used to tag a serialized monad.
35
+ JSON_CLASS_KEY = "json_class"
36
+
37
+ # Called for every object the generator cannot write natively.
38
+ #
39
+ # The second argument is `true` when the object is a hash key and `false` everywhere else. We
40
+ # do not use it, because a monad never appears as a key, but it must be accepted.
41
+ #
42
+ # @api private
43
+ AS_JSON = lambda { |object, *|
44
+ case object
45
+ when Maybe
46
+ {JSON_CLASS_KEY => object.class.name, "value" => object.none? ? nil : object.value!}
47
+ else
48
+ object
49
+ end
50
+ }
51
+
52
+ # Called for every value the parser produces, innermost first.
53
+ #
54
+ # @api private
55
+ ON_LOAD = lambda { |value|
56
+ next value unless value.is_a?(::Hash) && value.key?(JSON_CLASS_KEY)
57
+
58
+ case value[JSON_CLASS_KEY]
59
+ when Maybe::Some.name then Maybe::Some.new(value["value"])
60
+ when Maybe::None.name then Maybe::None.instance
61
+ else value
62
+ end
63
+ }
64
+ end
65
+
66
+ # Returns a coder that reads and writes monads. Unknown objects raise `JSON::GeneratorError`.
67
+ #
68
+ # A `JSON::Coder` is frozen on creation, so you cannot add monad support to an existing coder.
69
+ # Build your coder here instead: provide `as_json:` to write your own types, and `on_load:` to
70
+ # read them back. Both will run after the monad callbacks, and both see every value, so pass
71
+ # through anything you do not recognize.
72
+ #
73
+ # @example a coder that reads and writes monads and times
74
+ # coder = Dry::Monads.json_coder(
75
+ # as_json: ->(object, *) {
76
+ # object.is_a?(Time) ? {"json_class" => "Time", "value" => object.iso8601} : object
77
+ # },
78
+ # on_load: ->(value) {
79
+ # if value.is_a?(Hash) && value["json_class"] == "Time"
80
+ # Time.iso8601(value["value"])
81
+ # else
82
+ # value
83
+ # end
84
+ # }
85
+ # )
86
+ #
87
+ # coder.dump(Some(Time.utc(2026)))
88
+ # # => %({"json_class":"Dry::Monads::Maybe::Some","value":{"json_class":"Time","value":"2026-01-01T00:00:00Z"}})
89
+ #
90
+ # Monads nest inside your types and the other way around, because the generator and the
91
+ # parser walk the whole document and call both callbacks at every step.
92
+ #
93
+ # `as_json:` takes a second argument, which is `true` when the object is a hash key, and `false`
94
+ # everywhere else. Use it if you write a type that can be a key, because for a key you must
95
+ # return a String or a Symbol. Anything else raises `JSON::GeneratorError`:
96
+ #
97
+ # as_json: ->(object, as_key) {
98
+ # next object unless object.is_a?(Time)
99
+ # as_key ? object.iso8601 : {"json_class" => "Time", "value" => object.iso8601}
100
+ # }
101
+ #
102
+ # Even if you don't need this argument, your lambda must accept it (as in `->(object, *)`), or
103
+ # you will see an `ArgumentError` on the first dump.
104
+ #
105
+ # @param as_json [#call, nil] runs on every object the generator cannot write natively, after
106
+ # the monad callback
107
+ # @param on_load [#call, nil] runs on every parsed value, after the monad callback
108
+ # @param options [Hash] passed on to `JSON::Coder.new`
109
+ # @return [JSON::Coder]
110
+ #
111
+ # @api public
112
+ def self.json_coder(as_json: nil, on_load: nil, **options, &block)
113
+ # `JSON::Coder.new` takes its `as_json` callback as a block. We've opted to make it an keyword
114
+ # argument for consistency alongside `on_load:`.
115
+ #
116
+ # A user familiar with `JSON::Coder` may provide a block, so raise an error just in case.
117
+ raise ArgumentError, "pass the as_json callback as `as_json:`, not as a block" if block
118
+
119
+ ::JSON::Coder.new(
120
+ on_load: on_load ? JSONCoder::ON_LOAD >> on_load : JSONCoder::ON_LOAD,
121
+ **options
122
+ ) do |object, as_key|
123
+ json = JSONCoder::AS_JSON.call(object, as_key)
124
+ as_json ? as_json.call(json, as_key) : json
125
+ end
126
+ end
127
+ end
128
+ end
@@ -13,3 +13,7 @@ end
13
13
  Dry::Monads.register_extension(:pretty_print) do
14
14
  require "dry/monads/extensions/pretty_print"
15
15
  end
16
+
17
+ Dry::Monads.register_extension(:json) do
18
+ require "dry/monads/extensions/json"
19
+ end
@@ -142,9 +142,9 @@ module Dry
142
142
  def maybe(...) = Maybe.coerce(bind(...))
143
143
 
144
144
  # Accepts a block and runs it against the wrapped value.
145
- # If the block returns a trurhy value the result is self,
145
+ # If the block returns a truthy value the result is self,
146
146
  # otherwise None. If no block is given, the value serves
147
- # and its result.
147
+ # as its result.
148
148
  #
149
149
  # @param with [#call] positional block
150
150
  # @param block [Proc] block
@@ -46,7 +46,7 @@ module Dry
46
46
  def bind(...)
47
47
  # See https://typelevel.org/cats/datatypes/validated.html for details on why
48
48
  raise NotImplementedError,
49
- "Validated is not a monad because it would violate the monad laws"
49
+ "Validated is not a monad because it would violate the monad laws"
50
50
  end
51
51
 
52
52
  # Valid result
@@ -3,6 +3,6 @@
3
3
  module Dry
4
4
  module Monads
5
5
  # Gem version
6
- VERSION = "1.10.0"
6
+ VERSION = "1.11.0"
7
7
  end
8
8
  end
data/lib/dry/monads.rb CHANGED
@@ -23,7 +23,6 @@ module Dry
23
23
  loader.ignore(
24
24
  "#{root}/dry-monads.rb",
25
25
  "#{root}/dry/monads/{all,constants,errors,registry,version}.rb",
26
- "#{root}/json/**/*.rb",
27
26
  "#{root}/dry/monads/extensions.rb",
28
27
  "#{root}/dry/monads/extensions/**/*.rb"
29
28
  )
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dry-monads
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.10.0
4
+ version: 1.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hanakai team
@@ -132,6 +132,7 @@ files:
132
132
  - lib/dry/monads/do/mixin.rb
133
133
  - lib/dry/monads/errors.rb
134
134
  - lib/dry/monads/extensions.rb
135
+ - lib/dry/monads/extensions/json.rb
135
136
  - lib/dry/monads/extensions/pretty_print.rb
136
137
  - lib/dry/monads/extensions/rspec.rb
137
138
  - lib/dry/monads/extensions/super_diff.rb
@@ -149,7 +150,6 @@ files:
149
150
  - lib/dry/monads/unit.rb
150
151
  - lib/dry/monads/validated.rb
151
152
  - lib/dry/monads/version.rb
152
- - lib/json/add/dry/monads/maybe.rb
153
153
  homepage: https://dry-rb.org/gems/dry-monads
154
154
  licenses:
155
155
  - MIT
@@ -165,7 +165,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
165
165
  requirements:
166
166
  - - ">="
167
167
  - !ruby/object:Gem::Version
168
- version: 3.1.0
168
+ version: '3.3'
169
169
  required_rubygems_version: !ruby/object:Gem::Requirement
170
170
  requirements:
171
171
  - - ">="
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: false
2
-
3
- require "json" unless defined?(JSON::JSON_LOADED) && JSON::JSON_LOADED
4
-
5
- require "dry/monads"
6
-
7
- # Inspired by standard library implementation
8
- # for Time serialization/deserialization see (json/lib/json/add/time.rb)
9
- #
10
- module Dry
11
- module Monads
12
- class Maybe
13
- # Deserializes JSON string by using Dry::Monads::Maybe#lift method
14
- def self.json_create(serialized)
15
- coerce(serialized.fetch("value"))
16
- end
17
-
18
- # Returns a hash, that will be turned into a JSON object and represent this
19
- # object.
20
- def as_json(*, **)
21
- {
22
- JSON.create_id => self.class.name,
23
- value: none? ? nil : @value
24
- }
25
- end
26
-
27
- # Stores class name (Dry::Monads::Maybe::Some or Dry::Monads::Maybe::None)
28
- # with the monad value as JSON string
29
- def to_json(...) = as_json.to_json(...)
30
- end
31
- end
32
- end