From 8532bb6136121b66b66e67197ef64e2bbc9f7389 Mon Sep 17 00:00:00 2001 From: Brandon Weaver Date: Tue, 19 Jan 2021 00:19:18 -0600 Subject: [PATCH] resolve #642 - Add Pattern Matching Resolves feature request #642 requesting the addition of Pattern Matching hooks for Ruby 2.7+ by introducing `to_h`, `deconstruct`, and `deconstruct_keys` to core classes. This change also addresses spec changes by gating pattern matching specs on Ruby 2.7+ to prevent failures in older versions. All specs have also been given a pattern matching spec matching their implementation to demonstrate potential usages. While demonstrational usages could dive into nested classes that are in `HTTP` this was avoided to keep tests isolated from eachother. --- lib/http/client.rb | 20 ++++++++++++ lib/http/connection.rb | 29 +++++++++++++++++ lib/http/content_type.rb | 20 ++++++++++++ lib/http/headers.rb | 27 ++++++++++++++++ lib/http/options.rb | 10 ++++++ lib/http/request.rb | 26 ++++++++++++++++ lib/http/request/body.rb | 20 ++++++++++++ lib/http/request/writer.rb | 22 +++++++++++++ lib/http/response.rb | 28 +++++++++++++++++ lib/http/response/body.rb | 23 ++++++++++++++ lib/http/response/inflater.rb | 19 +++++++++++ lib/http/response/status.rb | 20 ++++++++++++ lib/http/uri.rb | 26 ++++++++++++++++ spec/lib/http/client_spec.rb | 31 ++++++++++++++++++ spec/lib/http/connection_spec.rb | 36 +++++++++++++++++++++ spec/lib/http/content_type_spec.rb | 27 ++++++++++++++++ spec/lib/http/headers_spec.rb | 24 ++++++++++++++ spec/lib/http/options_spec.rb | 45 +++++++++++++++++++++++++++ spec/lib/http/request/body_spec.rb | 27 ++++++++++++++++ spec/lib/http/request/writer_spec.rb | 29 +++++++++++++++++ spec/lib/http/request_spec.rb | 33 ++++++++++++++++++++ spec/lib/http/response/body_spec.rb | 29 +++++++++++++++++ spec/lib/http/response/status_spec.rb | 31 ++++++++++++++++++ spec/lib/http/response_spec.rb | 43 +++++++++++++++++++++++++ spec/lib/http/uri_spec.rb | 33 ++++++++++++++++++++ 25 files changed, 678 insertions(+) diff --git a/lib/http/client.rb b/lib/http/client.rb index dfa05b16..6440dfd1 100644 --- a/lib/http/client.rb +++ b/lib/http/client.rb @@ -102,6 +102,26 @@ def close @state = :clean end + # Hash representation of a client + # + # @return [Hash[Symbol, Any]] + def to_h + { + connection: @connection, + state: @state, + } + end + + # Pattern matching interface + # + # @param keys [Array] + # Keys to be extracted + # + # @return [Hash[Symbol, Any]] + def deconstruct_keys(keys) + to_h.slice(*keys) + end + private def build_response(req, options) diff --git a/lib/http/connection.rb b/lib/http/connection.rb index 2f48e317..951c7223 100644 --- a/lib/http/connection.rb +++ b/lib/http/connection.rb @@ -145,6 +145,35 @@ def expired? !@conn_expires_at || @conn_expires_at < Time.now end + # Hash representation of a connection + # + # @return [Hash[Symbol, Any]] + def to_h + { + persistent: @persistent, + keep_alive_timeout: @keep_alive_timeout, + pending_request: @pending_request, + pending_response: @pending_response, + failed_proxy_connect: @failed_proxy_connect, + buffer: @buffer, + parser: @parser, + socket: @socket, + status_code: status_code, + http_version: http_version, + headers: headers + } + end + + # Pattern matching interface + # + # @param keys [Array] + # Keys to be extracted + # + # @return [Hash[Symbol, Any]] + def deconstruct_keys(keys) + to_h.slice(*keys) + end + private # Sets up SSL context and starts TLS if needed. diff --git a/lib/http/content_type.rb b/lib/http/content_type.rb index aa40d3a3..3cea3bbe 100644 --- a/lib/http/content_type.rb +++ b/lib/http/content_type.rb @@ -30,5 +30,25 @@ def initialize(mime_type = nil, charset = nil) @mime_type = mime_type @charset = charset end + + # Hash representaiton of ContentType + # + # @return [Hash[Symbol, Any]] + def to_h + { + mime_type: @mime_type, + charset: @charset, + } + end + + # Pattern matching interface + # + # @param keys [Array[Symbol]] + # Keys to extract + # + # @return [Hash[Symbol, Any]] + def deconstruct_keys(keys) + to_h.slice(*keys) + end end end diff --git a/lib/http/headers.rb b/lib/http/headers.rb index aad91ea3..3b248fc3 100644 --- a/lib/http/headers.rb +++ b/lib/http/headers.rb @@ -115,6 +115,22 @@ def to_h end alias to_hash to_h + # Pattern matching interface + # + # @param keys [Array[Symbol]] + # Keys to extract + # + # @return [Hash[Symbol, Any]] + def deconstruct_keys(keys) + underscored_keys_map = underscored_keys_mapping + + self + .to_h + .map { |k, v| [underscored_keys_map[k], v] } + .to_h + .slice(*keys) + end + # Returns headers key/value pairs. # # @return [Array<[String, String]>] @@ -122,6 +138,9 @@ def to_a @pile.map { |item| item[1..2] } end + # Adds pattern matching interface using `to_a` as a base + alias_method :deconstruct, :to_a + # Returns human-readable representation of `self` instance. # # @return [String] @@ -219,6 +238,14 @@ def coerce(object) private + # Underscored version of HTTP Header keys for + # Pattern Matching + # + # @return [Hash[String, Symbol]] + def underscored_keys_mapping + Hash[keys.map { |k| [k, k.tr('A-Z-', 'a-z_').to_sym] }] + end + # Transforms `name` to canonical HTTP header capitalization # # @param [String] name diff --git a/lib/http/options.rb b/lib/http/options.rb index 753057ab..f149b3af 100644 --- a/lib/http/options.rb +++ b/lib/http/options.rb @@ -169,6 +169,16 @@ def to_hash Hash[*hash_pairs] end + # Pattern matching interface + # + # @param keys [Array] + # Keys to be extracted + # + # @return [Hash[Symbol, Any]] + def deconstruct_keys(keys) + to_hash.slice(*keys) + end + def dup dupped = super yield(dupped) if block_given? diff --git a/lib/http/request.rb b/lib/http/request.rb index 8e99e3ee..eba2aac3 100644 --- a/lib/http/request.rb +++ b/lib/http/request.rb @@ -199,6 +199,32 @@ def inspect "#<#{self.class}/#{@version} #{verb.to_s.upcase} #{uri}>" end + # Hash representation of a request + # + # @return [Hash[Symbol, Any]] + def to_h + { + verb: @verb, + uri: @uri, + scheme: @scheme, + proxy: @proxy, + version: @version, + headers: @headers, + body: @body, + port: port + } + end + + # Pattern matching interface + # + # @param keys [Array] + # Keys to be extracted + # + # @return [Hash[Symbol, Any]] + def deconstruct_keys(keys) + to_h.slice(*keys) + end + private # @!attribute [r] host diff --git a/lib/http/request/body.rb b/lib/http/request/body.rb index 14af30b8..acb6d500 100644 --- a/lib/http/request/body.rb +++ b/lib/http/request/body.rb @@ -49,6 +49,26 @@ def ==(other) self.class == other.class && self.source == other.source # rubocop:disable Style/RedundantSelf end + # Hash representation of a + # + # @return [Hash[Symbol, Any]] + def to_h + { + source: @source, + size: size + } + end + + # Pattern matching interface + # + # @param keys [Array] + # Keys to be extracted + # + # @return [Hash[Symbol, Any]] + def deconstruct_keys(keys) + to_h.slice(*keys) + end + private def rewind(io) diff --git a/lib/http/request/writer.rb b/lib/http/request/writer.rb index 9f75028b..860e51e8 100644 --- a/lib/http/request/writer.rb +++ b/lib/http/request/writer.rb @@ -102,6 +102,28 @@ def chunked? @headers[Headers::TRANSFER_ENCODING] == CHUNKED end + # Hash representation of a + # + # @return [Hash[Symbol, Any]] + def to_h + { + body: @body, + socket: @socket, + headers: @headers, + request_header: @request_header, + } + end + + # Pattern matching interface + # + # @param keys [Array] + # Keys to be extracted + # + # @return [Hash[Symbol, Any]] + def deconstruct_keys(keys) + to_h.slice(*keys) + end + private def write(data) diff --git a/lib/http/response.rb b/lib/http/response.rb index 9f0d1f35..c9df38f0 100644 --- a/lib/http/response.rb +++ b/lib/http/response.rb @@ -90,6 +90,34 @@ def to_a [status.to_i, headers.to_h, body.to_s] end + # Adds pattern matching interface using `to_a` as a base + alias_method :deconstruct, :to_a + + # Returns a Hash of accessible properties + # + # @return [Hash[Symbol, Any]] + def to_h + { + version: @version, + request: @request, + status: @status, + headers: @headers, + proxy_headers: @proxy_headers, + body: @body, + status: @status, + } + end + + # Pattern matching interface + # + # @param keys [Array] + # Keys to be extracted + # + # @return [Hash[Symbol, Any]] + def deconstruct_keys(keys) + to_h.slice(*keys) + end + # Flushes body and returns self-reference # # @return [Response] diff --git a/lib/http/response/body.rb b/lib/http/response/body.rb index a1030043..1c46996d 100644 --- a/lib/http/response/body.rb +++ b/lib/http/response/body.rb @@ -72,6 +72,29 @@ def inspect "#<#{self.class}:#{object_id.to_s(16)} @streaming=#{!!@streaming}>" end + # Hash representation of a body + # + # @return [Hash[Symbol, Any]] + def to_h + { + stream: @stream, + connection: @connection, + streaming: @streaming, + contents: @contents, + encoding: @encoding, + } + end + + # Pattern matching interface + # + # @param keys [Array[Symbol]] + # Keys to extract + # + # @return [Hash[Symbol, Any]] + def deconstruct_keys(keys) + to_h.slice(*keys) + end + private # Retrieve encoding by name. If encoding cannot be found, default to binary. diff --git a/lib/http/response/inflater.rb b/lib/http/response/inflater.rb index 22ff5bc0..2c08a252 100644 --- a/lib/http/response/inflater.rb +++ b/lib/http/response/inflater.rb @@ -22,6 +22,25 @@ def readpartial(*args) chunk end + # Hash representation of an inflater + # + # @return [Hash[Symbol, Any]] + def to_h + { + connection: connection + } + end + + # Pattern matching interface + # + # @param keys [Array[Symbol]] + # Keys to extract + # + # @return [Hash[Symbol, Any]] + def deconstruct_keys(keys) + to_h.slice(*keys) + end + private def zstream diff --git a/lib/http/response/status.rb b/lib/http/response/status.rb index 2682d512..7e8a4fd9 100644 --- a/lib/http/response/status.rb +++ b/lib/http/response/status.rb @@ -139,6 +139,26 @@ def #{symbol}? # def bad_request? RUBY end + # Hash representation of a + # + # @return [Hash[Symbol, Any]] + def to_h + { + code: code, + reason: reason + } + end + + # Pattern matching interface + # + # @param keys [Array] + # Keys to be extracted + # + # @return [Hash[Symbol, Any]] + def deconstruct_keys(keys) + to_h.slice(*keys) + end + def __setobj__(obj) raise TypeError, "Expected #{obj.inspect} to respond to #to_i" unless obj.respond_to? :to_i diff --git a/lib/http/uri.rb b/lib/http/uri.rb index f694ed08..fdebc7cc 100644 --- a/lib/http/uri.rb +++ b/lib/http/uri.rb @@ -146,5 +146,31 @@ def to_s def inspect format("#<%s:0x%014x URI:%s>", self.class.name, object_id << 1, to_s) end + + # Hash representation of a URI + # + # @return [Hash[Symbol, Any]] + def to_h + { + scheme: scheme, + user: user, + password: password, + host: host, + port: port, + path: path, + query: query, + fragment: fragment, + } + end + + # Pattern matching interface for a URI + # + # @param keys [Array[Symbol]] + # Keys to extract from the URI + # + # @return [Hash[Symbol, Any]] + def deconstruct_keys(keys) + to_h.slice(*keys) + end end end diff --git a/spec/lib/http/client_spec.rb b/spec/lib/http/client_spec.rb index 0aef24d0..893c0a40 100644 --- a/spec/lib/http/client_spec.rb +++ b/spec/lib/http/client_spec.rb @@ -502,4 +502,35 @@ def on_error(request, error) end end end + + # Pattern Matching only exists in Ruby 2.7+, guard against execution of + # tests otherwise + if RUBY_VERSION >= '2.7' + describe '#to_h' do + let(:client) { described_class.new } + + it 'returns a Hash representation of a Client' do + expect(client.to_h).to include({ + connection: nil, + state: :clean + }) + end + end + + describe 'Pattern Matching' do + let(:client) { described_class.new } + + it 'can perform a pattern match' do + value = + case client + in state: :clean + true + else + false + end + + expect(value).to eq(true) + end + end + end end diff --git a/spec/lib/http/connection_spec.rb b/spec/lib/http/connection_spec.rb index d7c9c546..974312e4 100644 --- a/spec/lib/http/connection_spec.rb +++ b/spec/lib/http/connection_spec.rb @@ -63,4 +63,40 @@ expect(buffer).to eq "1234567890" end end + + # Pattern Matching only exists in Ruby 2.7+, guard against execution of + # tests otherwise + if RUBY_VERSION >= '2.7' + describe '#to_h' do + it 'returns a Hash representation of a Connection' do + expect(connection.to_h).to include({ + buffer: "", + failed_proxy_connect: false, + headers: a_kind_of(HTTP::Headers), + http_version: "0.0", + keep_alive_timeout: 5.0, + parser: a_kind_of(HTTP::Response::Parser), + pending_request: false, + pending_response: false, + persistent: false, + socket: socket, + status_code: 0, + }) + end + end + + describe 'Pattern Matching' do + it 'can perform a pattern match' do + value = + case connection + in status_code: 0, pending_request: false + true + else + false + end + + expect(value).to eq(true) + end + end + end end diff --git a/spec/lib/http/content_type_spec.rb b/spec/lib/http/content_type_spec.rb index 8175992c..368e8c9f 100644 --- a/spec/lib/http/content_type_spec.rb +++ b/spec/lib/http/content_type_spec.rb @@ -44,4 +44,31 @@ its(:charset) { is_expected.to eq "utf-8" } end end + + # Pattern Matching only exists in Ruby 2.7+, guard against execution of + # tests otherwise + if RUBY_VERSION >= '2.7' + describe '#to_h' do + it 'returns a Hash representation of a Content Type' do + expect(described_class.new('text/plain', 'utf-8').to_h).to include({ + charset: 'utf-8', + mime_type: 'text/plain' + }) + end + end + + describe 'Pattern Matching' do + it 'can perform a pattern match' do + value = + case described_class.new('text/plain', 'utf-8') + in mime_type: /text/ + true + else + false + end + + expect(value).to eq(true) + end + end + end end diff --git a/spec/lib/http/headers_spec.rb b/spec/lib/http/headers_spec.rb index abf62269..4c705c48 100644 --- a/spec/lib/http/headers_spec.rb +++ b/spec/lib/http/headers_spec.rb @@ -523,5 +523,29 @@ it "is aliased as .[]" do expect(described_class.method(:coerce)).to eq described_class.method(:[]) end + + # Pattern Matching only exists in Ruby 2.7+, guard against execution of + # tests otherwise + if RUBY_VERSION >= '2.7' + describe 'Pattern Matching' do + before do + headers.add :content_type, "application/json" + headers.add :set_cookie, "hoo=ray" + headers.add :set_cookie, "woo=hoo" + end + + it 'can perform a pattern match' do + value = + case headers + in content_type: /json/, set_cookie: [/hoo/, *] + true + else + false + end + + expect(value).to eq(true) + end + end + end end end diff --git a/spec/lib/http/options_spec.rb b/spec/lib/http/options_spec.rb index 7c81df0c..0f4fa00b 100644 --- a/spec/lib/http/options_spec.rb +++ b/spec/lib/http/options_spec.rb @@ -10,4 +10,49 @@ it "coerces to a Hash" do expect(subject.to_hash).to be_a(Hash) end + + # Pattern Matching only exists in Ruby 2.7+, guard against execution of + # tests otherwise + if RUBY_VERSION >= '2.7' + describe '#to_hash' do + it 'returns a Hash representation of Options' do + expect(subject.to_hash).to include({ + body: nil, + cookies: {}, + encoding: nil, + features: {}, + follow: nil, + form: nil, + headers: an_instance_of(HTTP::Headers), + json: nil, + keep_alive_timeout: 5, + nodelay: false, + params: nil, + persistent: nil, + proxy: {}, + response: :body, + socket_class: TCPSocket, + ssl: {}, + ssl_context: nil, + ssl_socket_class: OpenSSL::SSL::SSLSocket, + timeout_class: HTTP::Timeout::Null, + timeout_options: {}, + }) + end + end + + describe 'Pattern Matching' do + it 'can perform a pattern match' do + value = + case subject + in keep_alive_timeout: 5..10 + true + else + false + end + + expect(value).to eq(true) + end + end + end end diff --git a/spec/lib/http/request/body_spec.rb b/spec/lib/http/request/body_spec.rb index e609e5ac..f04105ae 100644 --- a/spec/lib/http/request/body_spec.rb +++ b/spec/lib/http/request/body_spec.rb @@ -208,4 +208,31 @@ end end end + + # Pattern Matching only exists in Ruby 2.7+, guard against execution of + # tests otherwise + if RUBY_VERSION >= '2.7' + describe '#to_h' do + it 'returns a Hash representation of a Body' do + expect(subject.to_h).to include({ + source: '', + size: 0 + }) + end + end + + describe 'Pattern Matching' do + it 'can perform a pattern match' do + value = + case subject + in size: 0 + true + else + false + end + + expect(value).to eq(true) + end + end + end end diff --git a/spec/lib/http/request/writer_spec.rb b/spec/lib/http/request/writer_spec.rb index 95c00036..c90ac079 100644 --- a/spec/lib/http/request/writer_spec.rb +++ b/spec/lib/http/request/writer_spec.rb @@ -107,4 +107,33 @@ end end end + + # Pattern Matching only exists in Ruby 2.7+, guard against execution of + # tests otherwise + if RUBY_VERSION >= '2.7' + describe '#to_h' do + it 'returns a Hash representation of a Writer' do + expect(subject.to_h).to include({ + body: a_kind_of(HTTP::Request::Body), + headers: a_kind_of(HTTP::Headers), + request_header: ["GET /test HTTP/1.1"], + socket: a_kind_of(StringIO) + }) + end + end + + describe 'Pattern Matching' do + it 'can perform a pattern match' do + value = + case subject + in request_header: [/GET .*/] + true + else + false + end + + expect(value).to eq(true) + end + end + end end diff --git a/spec/lib/http/request_spec.rb b/spec/lib/http/request_spec.rb index da4ba074..5b87800f 100644 --- a/spec/lib/http/request_spec.rb +++ b/spec/lib/http/request_spec.rb @@ -231,4 +231,37 @@ it { is_expected.to eq "#" } end + + # Pattern Matching only exists in Ruby 2.7+, guard against execution of + # tests otherwise + if RUBY_VERSION >= '2.7' + describe '#to_h' do + it 'returns a Hash representation of a Request' do + expect(request.to_h).to include({ + body: a_kind_of(HTTP::Request::Body), + headers: a_kind_of(HTTP::Headers), + port: 80, + proxy: {}, + scheme: :http, + uri: a_kind_of(HTTP::URI), + verb: :get, + version: "1.1" + }) + end + end + + describe 'Pattern Matching' do + it 'can perform a pattern match' do + value = + case request + in port: 80, verb: :get, version: /^1\.\d/ + true + else + false + end + + expect(value).to eq(true) + end + end + end end diff --git a/spec/lib/http/response/body_spec.rb b/spec/lib/http/response/body_spec.rb index 663eae43..a17a3983 100644 --- a/spec/lib/http/response/body_spec.rb +++ b/spec/lib/http/response/body_spec.rb @@ -82,4 +82,33 @@ end end end + + # Pattern Matching only exists in Ruby 2.7+, guard against execution of + # tests otherwise + if RUBY_VERSION >= '2.7' + describe '#to_h' do + it 'returns a Hash representation of a Body' do + expect(subject.to_h).to include({ + connection: connection, + contents: nil, + encoding: a_kind_of(Encoding), + streaming: nil + }) + end + end + + describe 'Pattern Matching' do + it 'can perform a pattern match' do + value = + case subject + in contents: nil + true + else + false + end + + expect(value).to eq(true) + end + end + end end diff --git a/spec/lib/http/response/status_spec.rb b/spec/lib/http/response/status_spec.rb index 6930ab97..04d8a064 100644 --- a/spec/lib/http/response/status_spec.rb +++ b/spec/lib/http/response/status_spec.rb @@ -250,4 +250,35 @@ expect(described_class.method(:coerce)).to eq described_class.method :[] end end + + # Pattern Matching only exists in Ruby 2.7+, guard against execution of + # tests otherwise + if RUBY_VERSION >= '2.7' + describe '#to_h' do + subject { described_class.new("200.0") } + + it 'returns a Hash representation of a Status' do + expect(subject.to_h).to include({ + code: 200, + reason: 'OK' + }) + end + end + + describe 'Pattern Matching' do + subject { described_class.new("200.0") } + + it 'can perform a pattern match' do + value = + case subject + in code: 200..299 + true + else + false + end + + expect(value).to eq(true) + end + end + end end diff --git a/spec/lib/http/response_spec.rb b/spec/lib/http/response_spec.rb index 398f7413..fb03010b 100644 --- a/spec/lib/http/response_spec.rb +++ b/spec/lib/http/response_spec.rb @@ -171,4 +171,47 @@ end it { is_expected.not_to be_chunked } end + + # Pattern Matching only exists in Ruby 2.7+, guard against execution of + # tests otherwise + if RUBY_VERSION >= '2.7' + describe '#to_h' do + it 'returns a Hash representation of a Response' do + expect(response.to_h).to include({ + body: "Hello world!", + headers: a_kind_of(HTTP::Headers), + proxy_headers: a_kind_of(HTTP::Headers), + request: a_kind_of(HTTP::Request), + status: a_kind_of(HTTP::Response::Status), + version: "1.1", + }) + end + end + + describe 'Pattern Matching' do + it 'can perform a pattern match' do + value = + case response + in body: /Hello/, version: /^1.\d/ + true + else + false + end + + expect(value).to eq(true) + end + + it 'can perform an array pattern match' do + value = + case response + in [200, *] + true + else + false + end + + expect(value).to eq(true) + end + end + end end diff --git a/spec/lib/http/uri_spec.rb b/spec/lib/http/uri_spec.rb index 7e91760e..d2eeb41c 100644 --- a/spec/lib/http/uri_spec.rb +++ b/spec/lib/http/uri_spec.rb @@ -29,4 +29,37 @@ expect(http_uri.to_s).to eq("http://example.com") end end + + # Pattern Matching only exists in Ruby 2.7+, guard against execution of + # tests otherwise + if RUBY_VERSION >= '2.7' + describe '#to_h' do + it 'returns a Hash representation of a URI' do + expect(http_uri.to_h).to include({ + fragment: nil, + host: "example.com", + password: nil, + path: "", + port: 80, + query: nil, + scheme: "http", + user: nil + }) + end + end + + describe 'Pattern Matching' do + it 'can perform a pattern match' do + value = + case http_uri + in host: /example/, port: 50..100, scheme: 'http' + true + else + false + end + + expect(value).to eq(true) + end + end + end end