Skip to content

Commit 38402b6

Browse files
committed
Add pattern matching support via deconstruct_keys
Closes #642.
1 parent 5f8e417 commit 38402b6

13 files changed

Lines changed: 368 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Pattern matching support (`deconstruct_keys`) for Response, Response::Status,
13+
Headers, ContentType, and URI (#642)
14+
1015
### Changed
1116

1217
- Bumped min llhttp dependency version

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,24 @@ and call `#readpartial` on it repeatedly until it returns `nil`:
104104
=> nil
105105
```
106106

107+
### Pattern Matching
108+
109+
Response objects support Ruby's pattern matching:
110+
111+
```ruby
112+
case HTTP.get("https://api.example.com/users")
113+
in { status: 200..299, body: body }
114+
JSON.parse(body.to_s)
115+
in { status: 404 }
116+
nil
117+
in { status: 400.. }
118+
raise "request failed"
119+
end
120+
```
121+
122+
Pattern matching is also supported on `HTTP::Response::Status`, `HTTP::Headers`,
123+
`HTTP::ContentType`, and `HTTP::URI`.
124+
107125
## Supported Ruby Versions
108126

109127
This library aims to support and is [tested against][build-link]

lib/http/content_type.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,21 @@ def initialize(mime_type = nil, charset = nil)
6969
@mime_type = mime_type
7070
@charset = charset
7171
end
72+
73+
# Pattern matching interface for matching against content type attributes
74+
#
75+
# @example
76+
# case response.content_type
77+
# in { mime_type: /json/ }
78+
# "JSON content"
79+
# end
80+
#
81+
# @param keys [Array<Symbol>, nil] keys to extract, or nil for all
82+
# @return [Hash{Symbol => Object}]
83+
# @api public
84+
def deconstruct_keys(keys)
85+
h = { mime_type: @mime_type, charset: @charset }
86+
keys ? h.slice(*keys) : h
87+
end
7288
end
7389
end

lib/http/headers.rb

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -186,16 +186,20 @@ def to_h
186186
keys.to_h { |k| [k, self[k]] }
187187
end
188188
# @!method to_hash
189-
# Returns Rack-compatible headers Hash
190-
#
191-
# @example
192-
# headers.to_hash
193-
#
194189
# @see #to_h
195190
# @return [Hash]
196-
# @api public
197191
alias to_hash to_h
198192

193+
# Pattern matching interface
194+
#
195+
# @example
196+
# headers.deconstruct_keys(%i[content_type])
197+
#
198+
# @param keys [Array<Symbol>, nil] keys to extract, or nil for all
199+
# @return [Hash{Symbol => Object}]
200+
# @api public
201+
def deconstruct_keys(keys) = @pile.map { |_, k, _| k }.to_h { |k| [k.tr("A-Z-", "a-z_").to_sym, self[k]] }.then { |h| keys ? h.slice(*keys) : h } # rubocop:disable Layout/LineLength
202+
199203
# Returns human-readable representation of self instance
200204
#
201205
# @example

lib/http/response.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,42 @@ def to_a
148148
[status.to_i, headers.to_h, body.to_s]
149149
end
150150

151+
# @!method deconstruct
152+
# Array pattern matching interface
153+
#
154+
# @example
155+
# response.deconstruct
156+
#
157+
# @see #to_a
158+
# @return [Array(Integer, Hash, String)]
159+
# @api public
160+
alias deconstruct to_a
161+
162+
# Pattern matching interface for matching against response attributes
163+
#
164+
# @example
165+
# case response
166+
# in { status: 200..299, body: /success/ }
167+
# "ok"
168+
# in { status: 400.. }
169+
# "error"
170+
# end
171+
#
172+
# @param keys [Array<Symbol>, nil] keys to extract, or nil for all
173+
# @return [Hash{Symbol => Object}]
174+
# @api public
175+
def deconstruct_keys(keys)
176+
h = {
177+
status: @status,
178+
version: @version,
179+
headers: @headers,
180+
body: @body,
181+
request: @request,
182+
proxy_headers: @proxy_headers
183+
}
184+
keys ? h.slice(*keys) : h
185+
end
186+
151187
# Flushes body and returns self-reference
152188
#
153189
# @example

lib/http/response/status.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,24 @@ def inspect
176176
"#<#{self.class} #{self}>"
177177
end
178178

179+
# Pattern matching interface for matching against status code and reason
180+
#
181+
# @example
182+
# case response.status
183+
# in { code: 200..299 }
184+
# "success"
185+
# in { code: 400.. }
186+
# "error"
187+
# end
188+
#
189+
# @param keys [Array<Symbol>, nil] keys to extract, or nil for all
190+
# @return [Hash{Symbol => Object}]
191+
# @api public
192+
def deconstruct_keys(keys)
193+
h = { code: code, reason: reason }
194+
keys ? h.slice(*keys) : h
195+
end
196+
179197
SYMBOLS.each do |code, symbol|
180198
class_eval <<-RUBY, __FILE__, __LINE__ + 1
181199
def #{symbol}? # def bad_request?

lib/http/uri.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,20 @@ def inspect
260260
format("#<%s:0x%014x URI:%s>", self.class, object_id << 1, self)
261261
end
262262

263+
# Pattern matching interface
264+
#
265+
# @example
266+
# uri.deconstruct_keys(%i[scheme host])
267+
#
268+
# @param keys [Array<Symbol>, nil] keys to extract, or nil for all
269+
# @return [Hash{Symbol => Object}]
270+
# @api public
271+
def deconstruct_keys(keys)
272+
h = { scheme: scheme, host: host, port: port, path: path,
273+
query: query, fragment: fragment, user: user, password: password }
274+
keys ? h.slice(*keys) : h
275+
end
276+
263277
private
264278

265279
# Adds or removes IPv6 brackets from a host

sig/http.rbs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ module HTTP
140140
def include?: (untyped name) -> bool
141141
def to_h: () -> Hash[String, untyped]
142142
alias to_hash to_h
143+
def deconstruct_keys: (Array[Symbol]? keys) -> Hash[Symbol, untyped]
143144
def to_a: () -> Array[Array[String]]
144145
def inspect: () -> String
145146
def keys: () -> Array[String]
@@ -213,6 +214,7 @@ module HTTP
213214
public
214215

215216
def initialize: (?String? mime_type, ?String? charset) -> void
217+
def deconstruct_keys: (Array[Symbol]? keys) -> Hash[Symbol, untyped]
216218
end
217219

218220
class Feature
@@ -480,6 +482,7 @@ module HTTP
480482
def to_s: () -> String
481483
alias to_str to_s
482484
def inspect: () -> String
485+
def deconstruct_keys: (Array[Symbol]? keys) -> Hash[Symbol, untyped]
483486

484487
# Delegated methods from Addressable::URI
485488
def scheme: () -> String?
@@ -659,6 +662,8 @@ module HTTP
659662
def connection: () -> untyped
660663
def uri: () -> URI
661664
def to_a: () -> Array[untyped]
665+
alias deconstruct to_a
666+
def deconstruct_keys: (Array[Symbol]? keys) -> Hash[Symbol, untyped]
662667
def flush: () -> Response
663668
def content_length: () -> Integer?
664669
def content_type: () -> ContentType
@@ -702,6 +707,7 @@ module HTTP
702707
def server_error?: () -> bool
703708
def to_sym: () -> Symbol?
704709
def inspect: () -> String
710+
def deconstruct_keys: (Array[Symbol]? keys) -> Hash[Symbol, untyped]
705711
def __setobj__: (untyped obj) -> void
706712
def __getobj__: () -> Integer
707713
end

test/http/content_type_test.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,43 @@
126126
end
127127
end
128128

129+
describe "#deconstruct_keys" do
130+
let(:content_type) { HTTP::ContentType.new("text/html", "utf-8") }
131+
132+
it "returns all keys when given nil" do
133+
assert_equal({ mime_type: "text/html", charset: "utf-8" }, content_type.deconstruct_keys(nil))
134+
end
135+
136+
it "returns only requested keys" do
137+
assert_equal({ mime_type: "text/html" }, content_type.deconstruct_keys([:mime_type]))
138+
end
139+
140+
it "excludes unrequested keys" do
141+
refute_includes content_type.deconstruct_keys([:mime_type]).keys, :charset
142+
end
143+
144+
it "returns empty hash for empty keys" do
145+
assert_equal({}, content_type.deconstruct_keys([]))
146+
end
147+
148+
it "returns nil values when attributes are nil" do
149+
ct = HTTP::ContentType.new
150+
151+
assert_equal({ mime_type: nil, charset: nil }, ct.deconstruct_keys(nil))
152+
end
153+
154+
it "supports pattern matching with case/in" do
155+
matched = case content_type
156+
in { mime_type: /html/ }
157+
true
158+
else
159+
false
160+
end
161+
162+
assert matched
163+
end
164+
end
165+
129166
describe "#initialize" do
130167
it "stores mime_type and charset" do
131168
ct = HTTP::ContentType.new("text/html", "utf-8")

test/http/headers_test.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,51 @@ def obj.inspect = "INSPECTED"
376376
end
377377
end
378378

379+
describe "#deconstruct_keys" do
380+
before do
381+
headers.add :content_type, "application/json"
382+
headers.add :set_cookie, "hoo=ray"
383+
headers.add :set_cookie, "woo=hoo"
384+
end
385+
386+
it "returns all keys as snake_case symbols when given nil" do
387+
result = headers.deconstruct_keys(nil)
388+
389+
assert_equal "application/json", result[:content_type]
390+
assert_equal %w[hoo=ray woo=hoo], result[:set_cookie]
391+
end
392+
393+
it "converts header names to snake_case symbols" do
394+
assert_includes headers.deconstruct_keys(nil).keys, :content_type
395+
assert_includes headers.deconstruct_keys(nil).keys, :set_cookie
396+
end
397+
398+
it "returns only requested keys" do
399+
result = headers.deconstruct_keys([:content_type])
400+
401+
assert_equal({ content_type: "application/json" }, result)
402+
end
403+
404+
it "excludes unrequested keys" do
405+
refute_includes headers.deconstruct_keys([:content_type]).keys, :set_cookie
406+
end
407+
408+
it "returns empty hash for empty keys" do
409+
assert_equal({}, headers.deconstruct_keys([]))
410+
end
411+
412+
it "supports pattern matching with case/in" do
413+
matched = case headers
414+
in { content_type: /json/ }
415+
true
416+
else
417+
false
418+
end
419+
420+
assert matched
421+
end
422+
end
423+
379424
describe "#inspect" do
380425
it "returns a human-readable representation" do
381426
headers.set :set_cookie, %w[hoo=ray woo=hoo]

0 commit comments

Comments
 (0)