solid-resp-ractor 0.1.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +262 -0
- data/lib/solid_resp_ractor/codec.rb +50 -0
- data/lib/solid_resp_ractor/encoder.rb +63 -0
- data/lib/solid_resp_ractor/errors.rb +25 -0
- data/lib/solid_resp_ractor/limits.rb +33 -0
- data/lib/solid_resp_ractor/reader.rb +347 -0
- data/lib/solid_resp_ractor/source.rb +80 -0
- data/lib/solid_resp_ractor/types.rb +79 -0
- data/lib/solid_resp_ractor/version.rb +5 -0
- data/lib/solid_resp_ractor.rb +22 -0
- metadata +88 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5a7e50be310bd21524ea7a372f0f47b4d463d78ab6710df53a807433fa64c096
|
|
4
|
+
data.tar.gz: 88310b01fef02d445ac6a7c1c7d5b097bdcb68c80b8657c65dff638b1d9b090a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0fe96b665160e1ea048e3fd18a1f6dc43a09423e7aceb6e5a27898590edfcbdc39ae798235cac25b5ec33a64e685dd7ccef4c8147c77823eda426aced1cd9522
|
|
7
|
+
data.tar.gz: 211d591e7acc734020c4641dd33915f7991ac55aa3bddcf674a5001eab4d1f0c91e2959e9f3beb036da17b971b970ec9dc1c4af4d11e787e279e947cf9036ee0
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Dependency-free RESP2/RESP3 stream reader.
|
|
10
|
+
- Immutable command encoder with injectable argument conversion.
|
|
11
|
+
- Injectable byte sources, selectors, clocks, error mappers, and value
|
|
12
|
+
handlers.
|
|
13
|
+
- Compatible and typed RESP3 handling modes.
|
|
14
|
+
- Configurable limits for blob size, collection cardinality, nesting depth,
|
|
15
|
+
and line length.
|
|
16
|
+
- Bounded stress coverage for large pipelines, fragmented streams, malformed
|
|
17
|
+
input, and parallel Ractors.
|
|
18
|
+
- Ractor-shareable default encoder with no global mutable state.
|
|
19
|
+
|
|
20
|
+
[0.1.0]: https://github.com/nicolasva/solid-resp-ractor/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nicolas Vandenbogaerde
|
|
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,262 @@
|
|
|
1
|
+
# Solid RESP Ractor
|
|
2
|
+
|
|
3
|
+
`solid-resp-ractor` is a dependency-free RESP2/RESP3 codec for Ruby. It keeps
|
|
4
|
+
protocol parsing independent from Redis client behavior and provides explicit
|
|
5
|
+
extension points for transports, event loops, error hierarchies, value
|
|
6
|
+
representation, and command argument conversion.
|
|
7
|
+
|
|
8
|
+
The gem has no global mutable state. Immutable encoder instances can be shared
|
|
9
|
+
between Ractors; readers remain local to the Ractor that owns their IO.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Add to your Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "solid-resp-ractor"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then run:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bundle install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Encoding commands
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "solid_resp_ractor"
|
|
29
|
+
|
|
30
|
+
SolidRespRactor.encode(["SET", "key", "value"])
|
|
31
|
+
# => "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Arrays are expanded by one level, which supports APIs that group command
|
|
35
|
+
arguments:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
SolidRespRactor.encode(["MSET", ["first", 1], ["second", 2]])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Use an immutable custom encoder for domain-specific values:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
argument_encoder = Object.new
|
|
45
|
+
|
|
46
|
+
def argument_encoder.call(value)
|
|
47
|
+
value.respond_to?(:to_wire) ? value.to_wire : value.to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
argument_encoder.freeze
|
|
51
|
+
|
|
52
|
+
encoder = SolidRespRactor::Encoder.new(argument_encoder: argument_encoder)
|
|
53
|
+
encoder.encode(["SET", "key", custom_value])
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Set `expand_arrays: false` when arrays represent individual values rather than
|
|
57
|
+
argument groups.
|
|
58
|
+
|
|
59
|
+
## Reusable codec configurations
|
|
60
|
+
|
|
61
|
+
`Codec` groups all extension points into one immutable object:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
codec = SolidRespRactor::Codec.new(
|
|
65
|
+
encoder: custom_encoder,
|
|
66
|
+
handler: SolidRespRactor::Handlers::Typed,
|
|
67
|
+
error_mapper: error_mapper,
|
|
68
|
+
limits: SolidRespRactor::Limits.new(max_blob_size: 64 * 1024 * 1024),
|
|
69
|
+
chunk_size: 32_768,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
payload = codec.encode(["PING"])
|
|
73
|
+
reader = codec.reader(socket, read_timeout: 1.0)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`SolidRespRactor::DEFAULT_CODEC` is Ractor-shareable. A custom codec is also
|
|
77
|
+
shareable when all injected collaborators are shareable.
|
|
78
|
+
|
|
79
|
+
## Reading RESP streams
|
|
80
|
+
|
|
81
|
+
The default reader accepts blocking IO objects such as `StringIO`, as well as
|
|
82
|
+
non-blocking TCP, Unix, and TLS-compatible streams:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
socket = TCPSocket.new("127.0.0.1", 6379)
|
|
86
|
+
reader = SolidRespRactor.reader(socket, read_timeout: 1.0)
|
|
87
|
+
|
|
88
|
+
socket.write(SolidRespRactor.encode(["PING"]))
|
|
89
|
+
reader.read
|
|
90
|
+
# => "PONG"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Use `exception: false` for pipelines or proxies that need error values:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
result = reader.read(exception: false)
|
|
97
|
+
|
|
98
|
+
if result.is_a?(SolidRespRactor::ResponseError)
|
|
99
|
+
warn result.message
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Nested errors are raised only after the complete aggregate has been consumed,
|
|
104
|
+
so the next frame remains synchronized.
|
|
105
|
+
|
|
106
|
+
## RESP3 types
|
|
107
|
+
|
|
108
|
+
The compatible handler returns ordinary Ruby values and intentionally unwraps
|
|
109
|
+
RESP3 sets, pushes, verbatim strings, and attributes:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
reader = SolidRespRactor::Reader.new(io)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Use the typed handler when those distinctions matter:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
reader = SolidRespRactor::Reader.new(
|
|
119
|
+
io,
|
|
120
|
+
handler: SolidRespRactor::Handlers::Typed,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
case (value = reader.read)
|
|
124
|
+
when SolidRespRactor::Types::Push
|
|
125
|
+
process_push(value.value)
|
|
126
|
+
when SolidRespRactor::Types::Attribute
|
|
127
|
+
process(value.value, metadata: value.attributes)
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Typed values include:
|
|
132
|
+
|
|
133
|
+
- `SolidRespRactor::Types::Set`
|
|
134
|
+
- `SolidRespRactor::Types::Push`
|
|
135
|
+
- `SolidRespRactor::Types::Verbatim`
|
|
136
|
+
- `SolidRespRactor::Types::Attribute`
|
|
137
|
+
|
|
138
|
+
RESP3 streamed blob strings, arrays, maps, and sets are supported as well as
|
|
139
|
+
special double values (`inf`, `-inf`, and `nan`).
|
|
140
|
+
|
|
141
|
+
## Resource limits
|
|
142
|
+
|
|
143
|
+
The default immutable limits protect generic protocol consumers from
|
|
144
|
+
unbounded allocations and nesting:
|
|
145
|
+
|
|
146
|
+
| Limit | Default |
|
|
147
|
+
|---|---:|
|
|
148
|
+
| Blob or chunked string | 512 MiB |
|
|
149
|
+
| Collection cardinality | 1,000,000 |
|
|
150
|
+
| Nesting depth | 128 |
|
|
151
|
+
| Line length | 64 KiB |
|
|
152
|
+
|
|
153
|
+
Provide stricter limits for untrusted peers:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
limits = SolidRespRactor::Limits.new(
|
|
157
|
+
max_blob_size: 8 * 1024 * 1024,
|
|
158
|
+
max_collection_size: 10_000,
|
|
159
|
+
max_nesting_depth: 32,
|
|
160
|
+
max_line_size: 8 * 1024,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
codec = SolidRespRactor::Codec.new(limits: limits)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Extension points
|
|
167
|
+
|
|
168
|
+
### Custom error mapping
|
|
169
|
+
|
|
170
|
+
Map RESP error frames into an application's existing exception hierarchy:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
error_mapper = lambda do |message, blob:|
|
|
174
|
+
MyProtocolError.new(message)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
reader = SolidRespRactor::Reader.new(io, error_mapper: error_mapper)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The mapper must return an `Exception`.
|
|
181
|
+
|
|
182
|
+
### Custom value handling
|
|
183
|
+
|
|
184
|
+
Every decoded value can be transformed:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
handler = lambda do |type, value|
|
|
188
|
+
Event.new(type:, value:)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
reader = SolidRespRactor::Reader.new(io, handler: handler)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Custom byte sources
|
|
195
|
+
|
|
196
|
+
A source only needs:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class Source
|
|
200
|
+
def read(timeout:)
|
|
201
|
+
# Return a non-empty String, or nil at EOF.
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def wait_readable(timeout)
|
|
205
|
+
# Optional polling API.
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
reader = SolidRespRactor::Reader.new(source: Source.new)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
This allows integration with event loops, in-memory transports, framed
|
|
213
|
+
protocols, instrumentation, or test fixtures without changing the parser.
|
|
214
|
+
|
|
215
|
+
The built-in `Sources::IO` also accepts custom `selector` and `clock` objects.
|
|
216
|
+
|
|
217
|
+
## Timeouts and blocking commands
|
|
218
|
+
|
|
219
|
+
`read_timeout: nil` waits indefinitely. Temporarily override a timeout without
|
|
220
|
+
rebuilding the reader:
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
reader.with_timeout(nil) do
|
|
224
|
+
reader.read
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`wait_readable(timeout)` polls without consuming bytes, which is useful for
|
|
229
|
+
subscriptions and event-driven clients.
|
|
230
|
+
|
|
231
|
+
## Ractor usage
|
|
232
|
+
|
|
233
|
+
The default encoder is shareable:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
encoder = SolidRespRactor::DEFAULT_ENCODER
|
|
237
|
+
|
|
238
|
+
ractor = Ractor.new(encoder) do |shared_encoder|
|
|
239
|
+
socket = TCPSocket.new("127.0.0.1", 6379)
|
|
240
|
+
reader = SolidRespRactor::Reader.new(socket, read_timeout: 1.0)
|
|
241
|
+
|
|
242
|
+
socket.write(shared_encoder.encode(["PING"]))
|
|
243
|
+
reader.read
|
|
244
|
+
ensure
|
|
245
|
+
socket&.close
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
ractor.take
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Create sockets and readers inside their owning Ractor. Custom handlers,
|
|
252
|
+
encoders, selectors, and clocks must themselves be Ractor-shareable if they
|
|
253
|
+
are passed between Ractors.
|
|
254
|
+
|
|
255
|
+
## Development
|
|
256
|
+
|
|
257
|
+
```sh
|
|
258
|
+
bundle install
|
|
259
|
+
bundle exec rake
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
The default task runs the tests and builds the gem.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRespRactor
|
|
4
|
+
class Codec
|
|
5
|
+
attr_reader :encoder, :handler, :error_mapper, :limits, :chunk_size,
|
|
6
|
+
:selector, :clock
|
|
7
|
+
|
|
8
|
+
def initialize(
|
|
9
|
+
encoder: DEFAULT_ENCODER,
|
|
10
|
+
handler: Handlers::Compatible,
|
|
11
|
+
error_mapper: ErrorMappers::Default,
|
|
12
|
+
limits: Limits::DEFAULT,
|
|
13
|
+
chunk_size: 16_384,
|
|
14
|
+
selector: Selectors::IOSelect,
|
|
15
|
+
clock: Clocks::Monotonic
|
|
16
|
+
)
|
|
17
|
+
@encoder = encoder
|
|
18
|
+
@handler = handler
|
|
19
|
+
@error_mapper = error_mapper
|
|
20
|
+
@limits = limits
|
|
21
|
+
@chunk_size = Integer(chunk_size)
|
|
22
|
+
raise ArgumentError, "chunk_size must be positive" unless @chunk_size.positive?
|
|
23
|
+
|
|
24
|
+
@selector = selector
|
|
25
|
+
@clock = clock
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def encode(command)
|
|
30
|
+
@encoder.encode(command)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reader(io = nil, source: nil, read_timeout: nil)
|
|
34
|
+
Reader.new(
|
|
35
|
+
io,
|
|
36
|
+
source: source,
|
|
37
|
+
read_timeout: read_timeout,
|
|
38
|
+
handler: @handler,
|
|
39
|
+
error_mapper: @error_mapper,
|
|
40
|
+
limits: @limits,
|
|
41
|
+
chunk_size: @chunk_size,
|
|
42
|
+
selector: @selector,
|
|
43
|
+
clock: @clock,
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
DEFAULT_CODEC = Codec.new
|
|
49
|
+
Ractor.make_shareable(DEFAULT_CODEC) if defined?(Ractor)
|
|
50
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRespRactor
|
|
4
|
+
module ArgumentEncoders
|
|
5
|
+
module Default
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def call(value)
|
|
9
|
+
case value
|
|
10
|
+
when String then value
|
|
11
|
+
when Symbol, Integer, Float then value.to_s
|
|
12
|
+
when true then "1"
|
|
13
|
+
when false then "0"
|
|
14
|
+
when nil then ""
|
|
15
|
+
else value.to_s
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Encoder
|
|
22
|
+
CRLF = "\r\n"
|
|
23
|
+
|
|
24
|
+
attr_reader :argument_encoder
|
|
25
|
+
|
|
26
|
+
def initialize(argument_encoder: ArgumentEncoders::Default, expand_arrays: true)
|
|
27
|
+
@argument_encoder = argument_encoder
|
|
28
|
+
@expand_arrays = expand_arrays
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def encode(command)
|
|
33
|
+
length = command.sum { |argument| expanded?(argument) ? argument.length : 1 }
|
|
34
|
+
raise ArgumentError, "RESP command cannot be empty" if length.zero?
|
|
35
|
+
|
|
36
|
+
command.each_with_object(+"*#{length}#{CRLF}") do |argument, buffer|
|
|
37
|
+
if expanded?(argument)
|
|
38
|
+
argument.each { |value| append_argument(buffer, value) }
|
|
39
|
+
else
|
|
40
|
+
append_argument(buffer, argument)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def expanded?(argument)
|
|
48
|
+
@expand_arrays && argument.is_a?(Array)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def append_argument(buffer, argument)
|
|
52
|
+
value = @argument_encoder.call(argument)
|
|
53
|
+
unless value.is_a?(String)
|
|
54
|
+
raise TypeError, "argument encoder must return a String, got #{value.class}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
buffer << "$#{value.bytesize}#{CRLF}#{value}#{CRLF}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
DEFAULT_ENCODER = Encoder.new
|
|
62
|
+
Ractor.make_shareable(DEFAULT_ENCODER) if defined?(Ractor)
|
|
63
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRespRactor
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class ConnectionError < Error; end
|
|
6
|
+
class TimeoutError < ConnectionError; end
|
|
7
|
+
class ProtocolError < Error; end
|
|
8
|
+
class ResponseError < Error; end
|
|
9
|
+
class AuthenticationError < ResponseError; end
|
|
10
|
+
|
|
11
|
+
module ErrorMappers
|
|
12
|
+
module Default
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def call(message, blob:)
|
|
16
|
+
error_class = if message.start_with?("NOAUTH", "WRONGPASS")
|
|
17
|
+
AuthenticationError
|
|
18
|
+
else
|
|
19
|
+
ResponseError
|
|
20
|
+
end
|
|
21
|
+
error_class.new(message)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRespRactor
|
|
4
|
+
class Limits
|
|
5
|
+
attr_reader :max_blob_size, :max_collection_size, :max_nesting_depth,
|
|
6
|
+
:max_line_size
|
|
7
|
+
|
|
8
|
+
def initialize(
|
|
9
|
+
max_blob_size: 512 * 1024 * 1024,
|
|
10
|
+
max_collection_size: 1_000_000,
|
|
11
|
+
max_nesting_depth: 128,
|
|
12
|
+
max_line_size: 64 * 1024
|
|
13
|
+
)
|
|
14
|
+
@max_blob_size = positive_integer(max_blob_size, :max_blob_size)
|
|
15
|
+
@max_collection_size = positive_integer(max_collection_size, :max_collection_size)
|
|
16
|
+
@max_nesting_depth = positive_integer(max_nesting_depth, :max_nesting_depth)
|
|
17
|
+
@max_line_size = positive_integer(max_line_size, :max_line_size)
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def positive_integer(value, name)
|
|
24
|
+
value = Integer(value)
|
|
25
|
+
raise ArgumentError, "#{name} must be positive" unless value.positive?
|
|
26
|
+
|
|
27
|
+
value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
DEFAULT = new
|
|
31
|
+
Ractor.make_shareable(DEFAULT) if defined?(Ractor)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRespRactor
|
|
4
|
+
class Reader
|
|
5
|
+
CRLF = "\r\n"
|
|
6
|
+
|
|
7
|
+
attr_reader :source
|
|
8
|
+
|
|
9
|
+
def initialize(
|
|
10
|
+
io = nil,
|
|
11
|
+
source: nil,
|
|
12
|
+
read_timeout: nil,
|
|
13
|
+
handler: Handlers::Compatible,
|
|
14
|
+
error_mapper: ErrorMappers::Default,
|
|
15
|
+
limits: Limits::DEFAULT,
|
|
16
|
+
chunk_size: 16_384,
|
|
17
|
+
selector: Selectors::IOSelect,
|
|
18
|
+
clock: Clocks::Monotonic
|
|
19
|
+
)
|
|
20
|
+
if io && source
|
|
21
|
+
raise ArgumentError, "pass either io or source, not both"
|
|
22
|
+
end
|
|
23
|
+
raise ArgumentError, "io or source is required" unless io || source
|
|
24
|
+
|
|
25
|
+
@source = source || Sources::IO.new(
|
|
26
|
+
io,
|
|
27
|
+
chunk_size: chunk_size,
|
|
28
|
+
selector: selector,
|
|
29
|
+
clock: clock,
|
|
30
|
+
)
|
|
31
|
+
@read_timeout = read_timeout
|
|
32
|
+
@handler = handler
|
|
33
|
+
@error_mapper = error_mapper
|
|
34
|
+
@limits = limits
|
|
35
|
+
@buffer = +""
|
|
36
|
+
@offset = 0
|
|
37
|
+
@read_depth = 0
|
|
38
|
+
@captured_error = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def with_timeout(timeout)
|
|
42
|
+
previous = @read_timeout
|
|
43
|
+
@read_timeout = timeout
|
|
44
|
+
yield
|
|
45
|
+
ensure
|
|
46
|
+
@read_timeout = previous
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def wait_readable(timeout)
|
|
50
|
+
return true if available_bytes.positive?
|
|
51
|
+
return true unless @source.respond_to?(:wait_readable)
|
|
52
|
+
|
|
53
|
+
@source.wait_readable(timeout)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def read(exception: true)
|
|
57
|
+
top_level = @read_depth.zero?
|
|
58
|
+
@captured_error = nil if top_level
|
|
59
|
+
@read_depth += 1
|
|
60
|
+
if @read_depth > @limits.max_nesting_depth
|
|
61
|
+
raise ProtocolError, "RESP nesting exceeds #{@limits.max_nesting_depth}"
|
|
62
|
+
end
|
|
63
|
+
value = read_type(read_bytes(1))
|
|
64
|
+
raise @captured_error if top_level && exception && @captured_error
|
|
65
|
+
|
|
66
|
+
value
|
|
67
|
+
rescue EOFError
|
|
68
|
+
raise ConnectionError, "RESP stream closed"
|
|
69
|
+
ensure
|
|
70
|
+
@read_depth -= 1
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def read_type(type)
|
|
76
|
+
case type
|
|
77
|
+
when "+" then handle(:simple_string, read_line)
|
|
78
|
+
when "-" then error_response(read_line, blob: false)
|
|
79
|
+
when ":" then handle(:integer, parse_integer(read_line))
|
|
80
|
+
when "$" then read_blob
|
|
81
|
+
when "*" then read_array
|
|
82
|
+
when "_" then read_null
|
|
83
|
+
when "#" then read_boolean
|
|
84
|
+
when "," then handle(:double, parse_float(read_line))
|
|
85
|
+
when "(" then handle(:big_number, parse_integer(read_line))
|
|
86
|
+
when "%" then read_map
|
|
87
|
+
when "~" then read_collection(:set)
|
|
88
|
+
when ">" then read_collection(:push)
|
|
89
|
+
when "=" then read_verbatim
|
|
90
|
+
when "!" then error_response(read_sized_string(streamed: true), blob: true)
|
|
91
|
+
when "|" then read_attribute
|
|
92
|
+
else raise ProtocolError, "Unknown RESP type byte: #{type.inspect}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def read_blob
|
|
97
|
+
length = read_length(nullable: true, streamed: true)
|
|
98
|
+
return handle(:blob_string, nil) unless length
|
|
99
|
+
|
|
100
|
+
validate_blob_length(length) unless length == :streamed
|
|
101
|
+
value = length == :streamed ? read_chunked_string : read_sized_value(length)
|
|
102
|
+
handle(:blob_string, value)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def read_array
|
|
106
|
+
length = read_length(nullable: true, streamed: true)
|
|
107
|
+
return handle(:array, nil) unless length
|
|
108
|
+
|
|
109
|
+
validate_collection_length(length) unless length == :streamed
|
|
110
|
+
value = if length == :streamed
|
|
111
|
+
read_streamed_collection
|
|
112
|
+
else
|
|
113
|
+
Array.new(length) { read(exception: false) }
|
|
114
|
+
end
|
|
115
|
+
handle(:array, value)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def read_collection(type)
|
|
119
|
+
length = read_length(streamed: true)
|
|
120
|
+
validate_collection_length(length) unless length == :streamed
|
|
121
|
+
value = if length == :streamed
|
|
122
|
+
read_streamed_collection
|
|
123
|
+
else
|
|
124
|
+
Array.new(length) { read(exception: false) }
|
|
125
|
+
end
|
|
126
|
+
handle(type, value)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def read_map
|
|
130
|
+
length = read_length(streamed: true)
|
|
131
|
+
validate_collection_length(length) unless length == :streamed
|
|
132
|
+
value = if length == :streamed
|
|
133
|
+
read_streamed_map
|
|
134
|
+
else
|
|
135
|
+
{}.tap do |result|
|
|
136
|
+
length.times { result[read(exception: false)] = read(exception: false) }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
handle(:map, value)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def read_streamed_collection
|
|
143
|
+
values = []
|
|
144
|
+
loop do
|
|
145
|
+
type = read_bytes(1)
|
|
146
|
+
break if aggregate_end?(type)
|
|
147
|
+
|
|
148
|
+
values << read_type(type)
|
|
149
|
+
validate_collection_length(values.length)
|
|
150
|
+
end
|
|
151
|
+
values
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def read_streamed_map
|
|
155
|
+
{}.tap do |result|
|
|
156
|
+
count = 0
|
|
157
|
+
loop do
|
|
158
|
+
type = read_bytes(1)
|
|
159
|
+
break if aggregate_end?(type)
|
|
160
|
+
|
|
161
|
+
key = read_type(type)
|
|
162
|
+
result[key] = read(exception: false)
|
|
163
|
+
count += 1
|
|
164
|
+
validate_collection_length(count)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def aggregate_end?(type)
|
|
170
|
+
return false unless type == "."
|
|
171
|
+
|
|
172
|
+
value = read_line
|
|
173
|
+
raise ProtocolError, "RESP aggregate terminator must not contain data" unless value.empty?
|
|
174
|
+
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def read_attribute
|
|
179
|
+
attributes = read_map
|
|
180
|
+
value = read(exception: false)
|
|
181
|
+
handle(:attribute, [attributes, value])
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def read_null
|
|
185
|
+
value = read_line
|
|
186
|
+
raise ProtocolError, "RESP null must not contain data" unless value.empty?
|
|
187
|
+
|
|
188
|
+
handle(:null, nil)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def read_boolean
|
|
192
|
+
case (value = read_line)
|
|
193
|
+
when "t" then handle(:boolean, true)
|
|
194
|
+
when "f" then handle(:boolean, false)
|
|
195
|
+
else raise ProtocolError, "Invalid RESP boolean: #{value.inspect}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def read_verbatim
|
|
200
|
+
value = read_sized_string(streamed: true)
|
|
201
|
+
unless value.bytesize >= 4 && value.byteslice(3, 1) == ":"
|
|
202
|
+
raise ProtocolError, "Invalid RESP verbatim string"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
handle(:verbatim, [value.byteslice(0, 3).freeze, value.byteslice(4..)])
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def read_sized_string(streamed: false)
|
|
209
|
+
length = read_length(streamed: streamed)
|
|
210
|
+
validate_blob_length(length) unless length == :streamed
|
|
211
|
+
length == :streamed ? read_chunked_string : read_sized_value(length)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def read_chunked_string
|
|
215
|
+
buffer = +""
|
|
216
|
+
loop do
|
|
217
|
+
type = read_bytes(1)
|
|
218
|
+
raise ProtocolError, "Expected RESP chunk, got #{type.inspect}" unless type == ";"
|
|
219
|
+
|
|
220
|
+
length = read_length
|
|
221
|
+
break if length.zero?
|
|
222
|
+
|
|
223
|
+
validate_blob_length(buffer.bytesize + length)
|
|
224
|
+
buffer << read_sized_value(length)
|
|
225
|
+
end
|
|
226
|
+
buffer
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def read_sized_value(length)
|
|
230
|
+
value = read_bytes(length)
|
|
231
|
+
actual = read_bytes(2)
|
|
232
|
+
raise ProtocolError, "Expected CRLF, got #{actual.inspect}" unless actual == CRLF
|
|
233
|
+
|
|
234
|
+
value
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def read_length(nullable: false, streamed: false)
|
|
238
|
+
value = read_line
|
|
239
|
+
return :streamed if streamed && value == "?"
|
|
240
|
+
|
|
241
|
+
length = parse_integer(value)
|
|
242
|
+
return if nullable && length == -1
|
|
243
|
+
raise ProtocolError, "Invalid RESP length: #{length}" if length.negative?
|
|
244
|
+
|
|
245
|
+
length
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def parse_integer(value)
|
|
249
|
+
Integer(value)
|
|
250
|
+
rescue ArgumentError
|
|
251
|
+
raise ProtocolError, "Invalid RESP integer: #{value.inspect}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def parse_float(value)
|
|
255
|
+
return Float::INFINITY if value == "inf"
|
|
256
|
+
return -Float::INFINITY if value == "-inf"
|
|
257
|
+
return Float::NAN if value == "nan"
|
|
258
|
+
|
|
259
|
+
Float(value)
|
|
260
|
+
rescue ArgumentError
|
|
261
|
+
raise ProtocolError, "Invalid RESP double: #{value.inspect}"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def read_line
|
|
265
|
+
loop do
|
|
266
|
+
if (index = @buffer.index(CRLF, @offset))
|
|
267
|
+
line_length = index - @offset
|
|
268
|
+
if line_length > @limits.max_line_size
|
|
269
|
+
raise ProtocolError, "RESP line exceeds #{@limits.max_line_size} bytes"
|
|
270
|
+
end
|
|
271
|
+
value = @buffer.byteslice(@offset, line_length)
|
|
272
|
+
@offset = index + 2
|
|
273
|
+
clear_consumed_buffer
|
|
274
|
+
return value
|
|
275
|
+
end
|
|
276
|
+
if available_bytes > @limits.max_line_size
|
|
277
|
+
raise ProtocolError, "RESP line exceeds #{@limits.max_line_size} bytes"
|
|
278
|
+
end
|
|
279
|
+
fill_buffer
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def read_bytes(length)
|
|
284
|
+
fill_buffer while available_bytes < length
|
|
285
|
+
value = @buffer.byteslice(@offset, length)
|
|
286
|
+
@offset += length
|
|
287
|
+
clear_consumed_buffer
|
|
288
|
+
value
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def fill_buffer
|
|
292
|
+
compact_buffer
|
|
293
|
+
chunk = @source.read(timeout: @read_timeout)
|
|
294
|
+
raise EOFError if chunk.nil?
|
|
295
|
+
unless chunk.is_a?(String)
|
|
296
|
+
raise ProtocolError, "RESP source must return a String or nil, got #{chunk.class}"
|
|
297
|
+
end
|
|
298
|
+
raise ProtocolError, "RESP source returned an empty chunk" if chunk.empty?
|
|
299
|
+
|
|
300
|
+
@buffer << chunk
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def available_bytes
|
|
304
|
+
@buffer.bytesize - @offset
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def clear_consumed_buffer
|
|
308
|
+
return unless @offset == @buffer.bytesize
|
|
309
|
+
|
|
310
|
+
@buffer.clear
|
|
311
|
+
@offset = 0
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def compact_buffer
|
|
315
|
+
return if @offset.zero?
|
|
316
|
+
|
|
317
|
+
@buffer = @buffer.byteslice(@offset..) || +""
|
|
318
|
+
@offset = 0
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def error_response(message, blob:)
|
|
322
|
+
error = @error_mapper.call(message, blob: blob)
|
|
323
|
+
unless error.is_a?(Exception)
|
|
324
|
+
raise TypeError, "error mapper must return an Exception, got #{error.class}"
|
|
325
|
+
end
|
|
326
|
+
@captured_error ||= error
|
|
327
|
+
error
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def validate_blob_length(length)
|
|
331
|
+
return if length <= @limits.max_blob_size
|
|
332
|
+
|
|
333
|
+
raise ProtocolError, "RESP blob exceeds #{@limits.max_blob_size} bytes"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def validate_collection_length(length)
|
|
337
|
+
return if length <= @limits.max_collection_size
|
|
338
|
+
|
|
339
|
+
raise ProtocolError,
|
|
340
|
+
"RESP collection exceeds #{@limits.max_collection_size} elements"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def handle(type, value)
|
|
344
|
+
@handler.call(type, value)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRespRactor
|
|
4
|
+
module Clocks
|
|
5
|
+
module Monotonic
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def now
|
|
9
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module Selectors
|
|
15
|
+
module IOSelect
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def call(readers, writers, timeout)
|
|
19
|
+
IO.select(readers, writers, nil, timeout)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module Sources
|
|
25
|
+
class IO
|
|
26
|
+
def initialize(
|
|
27
|
+
io,
|
|
28
|
+
chunk_size: 16_384,
|
|
29
|
+
selector: Selectors::IOSelect,
|
|
30
|
+
clock: Clocks::Monotonic
|
|
31
|
+
)
|
|
32
|
+
@io = io
|
|
33
|
+
@chunk_size = Integer(chunk_size)
|
|
34
|
+
raise ArgumentError, "chunk_size must be positive" unless @chunk_size.positive?
|
|
35
|
+
|
|
36
|
+
@selector = selector
|
|
37
|
+
@clock = clock
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def read(timeout:)
|
|
41
|
+
return blocking_read unless @io.respond_to?(:read_nonblock)
|
|
42
|
+
|
|
43
|
+
deadline = nil
|
|
44
|
+
loop do
|
|
45
|
+
chunk = @io.read_nonblock(@chunk_size, exception: false)
|
|
46
|
+
return chunk unless chunk == :wait_readable || chunk == :wait_writable
|
|
47
|
+
|
|
48
|
+
deadline ||= @clock.now + timeout if timeout
|
|
49
|
+
remaining = deadline && deadline - @clock.now
|
|
50
|
+
raise_timeout(timeout) if remaining && remaining <= 0
|
|
51
|
+
|
|
52
|
+
selectable = @io.respond_to?(:to_io) ? @io.to_io : @io
|
|
53
|
+
readers = chunk == :wait_readable ? [selectable] : nil
|
|
54
|
+
writers = chunk == :wait_writable ? [selectable] : nil
|
|
55
|
+
raise_timeout(timeout) unless @selector.call(readers, writers, remaining)
|
|
56
|
+
end
|
|
57
|
+
rescue IOError, SystemCallError => error
|
|
58
|
+
raise ConnectionError, error.message, cause: error
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def wait_readable(timeout)
|
|
62
|
+
return true unless @io.respond_to?(:to_io)
|
|
63
|
+
|
|
64
|
+
!@selector.call([@io.to_io], nil, timeout).nil?
|
|
65
|
+
rescue IOError, SystemCallError => error
|
|
66
|
+
raise ConnectionError, error.message, cause: error
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def blocking_read
|
|
72
|
+
@io.read(@chunk_size)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def raise_timeout(timeout)
|
|
76
|
+
raise TimeoutError, "RESP read timed out after #{timeout}s"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidRespRactor
|
|
4
|
+
module Types
|
|
5
|
+
class Collection
|
|
6
|
+
attr_reader :value
|
|
7
|
+
|
|
8
|
+
def initialize(value)
|
|
9
|
+
@value = value
|
|
10
|
+
freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def ==(other)
|
|
14
|
+
other.instance_of?(self.class) && other.value == value
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Set < Collection; end
|
|
19
|
+
class Push < Collection; end
|
|
20
|
+
|
|
21
|
+
class Verbatim
|
|
22
|
+
attr_reader :format, :data
|
|
23
|
+
|
|
24
|
+
def initialize(format, data)
|
|
25
|
+
@format = format.freeze
|
|
26
|
+
@data = data
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def ==(other)
|
|
31
|
+
other.instance_of?(self.class) && other.format == format && other.data == data
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class Attribute
|
|
36
|
+
attr_reader :attributes, :value
|
|
37
|
+
|
|
38
|
+
def initialize(attributes, value)
|
|
39
|
+
@attributes = attributes
|
|
40
|
+
@value = value
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ==(other)
|
|
45
|
+
other.instance_of?(self.class) &&
|
|
46
|
+
other.attributes == attributes &&
|
|
47
|
+
other.value == value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
module Handlers
|
|
53
|
+
module Compatible
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
def call(type, value)
|
|
57
|
+
case type
|
|
58
|
+
when :verbatim then value.fetch(1)
|
|
59
|
+
when :attribute then value.fetch(1)
|
|
60
|
+
else value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
module Typed
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
def call(type, value)
|
|
69
|
+
case type
|
|
70
|
+
when :set then Types::Set.new(value)
|
|
71
|
+
when :push then Types::Push.new(value)
|
|
72
|
+
when :verbatim then Types::Verbatim.new(*value)
|
|
73
|
+
when :attribute then Types::Attribute.new(*value)
|
|
74
|
+
else value
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "solid_resp_ractor/version"
|
|
4
|
+
require_relative "solid_resp_ractor/errors"
|
|
5
|
+
require_relative "solid_resp_ractor/types"
|
|
6
|
+
require_relative "solid_resp_ractor/limits"
|
|
7
|
+
require_relative "solid_resp_ractor/encoder"
|
|
8
|
+
require_relative "solid_resp_ractor/source"
|
|
9
|
+
require_relative "solid_resp_ractor/reader"
|
|
10
|
+
require_relative "solid_resp_ractor/codec"
|
|
11
|
+
|
|
12
|
+
module SolidRespRactor
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def encode(command, encoder: DEFAULT_ENCODER)
|
|
16
|
+
encoder.encode(command)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reader(io = nil, source: nil, read_timeout: nil, codec: DEFAULT_CODEC)
|
|
20
|
+
codec.reader(io, source: source, read_timeout: read_timeout)
|
|
21
|
+
end
|
|
22
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: solid-resp-ractor
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Nicolas Vandenbogaerde
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '7'
|
|
22
|
+
type: :development
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '5'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '7'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: rake
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '13.0'
|
|
39
|
+
type: :development
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '13.0'
|
|
46
|
+
description: Dependency-free RESP command encoding and stream decoding with injectable
|
|
47
|
+
sources, selectors, clocks, error mappers, and RESP3 type handlers.
|
|
48
|
+
executables: []
|
|
49
|
+
extensions: []
|
|
50
|
+
extra_rdoc_files: []
|
|
51
|
+
files:
|
|
52
|
+
- CHANGELOG.md
|
|
53
|
+
- LICENSE.txt
|
|
54
|
+
- README.md
|
|
55
|
+
- lib/solid_resp_ractor.rb
|
|
56
|
+
- lib/solid_resp_ractor/codec.rb
|
|
57
|
+
- lib/solid_resp_ractor/encoder.rb
|
|
58
|
+
- lib/solid_resp_ractor/errors.rb
|
|
59
|
+
- lib/solid_resp_ractor/limits.rb
|
|
60
|
+
- lib/solid_resp_ractor/reader.rb
|
|
61
|
+
- lib/solid_resp_ractor/source.rb
|
|
62
|
+
- lib/solid_resp_ractor/types.rb
|
|
63
|
+
- lib/solid_resp_ractor/version.rb
|
|
64
|
+
homepage: https://github.com/nicolasva/solid-resp-ractor
|
|
65
|
+
licenses:
|
|
66
|
+
- MIT
|
|
67
|
+
metadata:
|
|
68
|
+
rubygems_mfa_required: 'true'
|
|
69
|
+
source_code_uri: https://github.com/nicolasva/solid-resp-ractor
|
|
70
|
+
changelog_uri: https://github.com/nicolasva/solid-resp-ractor/blob/main/CHANGELOG.md
|
|
71
|
+
rdoc_options: []
|
|
72
|
+
require_paths:
|
|
73
|
+
- lib
|
|
74
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '3.1'
|
|
79
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '0'
|
|
84
|
+
requirements: []
|
|
85
|
+
rubygems_version: 4.0.20
|
|
86
|
+
specification_version: 4
|
|
87
|
+
summary: A modular, Ractor-friendly RESP2/RESP3 codec
|
|
88
|
+
test_files: []
|