Skip to content

Commit b46e8bc

Browse files
committed
WIP
1 parent 95e8a8c commit b46e8bc

File tree

1 file changed

+248
-0
lines changed

1 file changed

+248
-0
lines changed

lib/async/redis/endpoint.rb

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2024, by Samuel Williams.
5+
6+
require 'io/endpoint'
7+
require 'io/endpoint/host_endpoint'
8+
require 'io/endpoint/ssl_endpoint'
9+
10+
module Async
11+
module Redis
12+
# Represents a way to connect to a remote HTTP server.
13+
class Endpoint < ::IO::Endpoint::Generic
14+
SCHEMES = {
15+
'redis' => URI::HTTP,
16+
'rediss' => URI::HTTPS,
17+
}
18+
19+
def self.parse(string, endpoint = nil, **options)
20+
url = URI.parse(string).normalize
21+
22+
return self.new(url, endpoint, **options)
23+
end
24+
25+
# Construct an endpoint with a specified scheme, hostname, optional path, and options.
26+
#
27+
# @parameter scheme [String] The scheme to use, e.g. "http" or "https".
28+
# @parameter hostname [String] The hostname to connect to (or bind to).
29+
# @parameter *options [Hash] Additional options, passed to {#initialize}.
30+
def self.for(scheme, hostname, path = "/", **options)
31+
uri_klass = SCHEMES.fetch(scheme.downcase) do
32+
raise ArgumentError, "Unsupported scheme: #{scheme.inspect}"
33+
end
34+
35+
self.new(
36+
uri_klass.new(scheme, nil, hostname, nil, nil, path, nil, nil, nil).normalize,
37+
**options
38+
)
39+
end
40+
41+
# Coerce the given object into an endpoint.
42+
# @parameter url [String | Endpoint] The URL or endpoint to convert.
43+
def self.[](url)
44+
if url.is_a?(Endpoint)
45+
return url
46+
else
47+
Endpoint.parse(url.to_s)
48+
end
49+
end
50+
51+
# @option scheme [String] the scheme to use, overrides the URL scheme.
52+
# @option hostname [String] the hostname to connect to (or bind to), overrides the URL hostname (used for SNI).
53+
# @option port [Integer] the port to bind to, overrides the URL port.
54+
# @option ssl_context [OpenSSL::SSL::SSLContext] the context to use for TLS.
55+
# @option alpn_protocols [Array<String>] the alpn protocols to negotiate.
56+
def initialize(url, endpoint = nil, **options)
57+
super(**options)
58+
59+
raise ArgumentError, "URL must be absolute (include scheme, host): #{url}" unless url.absolute?
60+
61+
@url = url
62+
63+
if endpoint
64+
@endpoint = self.build_endpoint(endpoint)
65+
else
66+
@endpoint = nil
67+
end
68+
end
69+
70+
def to_url
71+
url = @url.dup
72+
73+
unless default_port?
74+
url.port = self.port
75+
end
76+
77+
return url
78+
end
79+
80+
def to_s
81+
"\#<#{self.class} #{self.to_url} #{@options}>"
82+
end
83+
84+
def inspect
85+
"\#<#{self.class} #{self.to_url} #{@options.inspect}>"
86+
end
87+
88+
attr :url
89+
90+
def address
91+
endpoint.address
92+
end
93+
94+
def secure?
95+
['https', 'wss'].include?(self.scheme)
96+
end
97+
98+
def protocol
99+
@options.fetch(:protocol) do
100+
if secure?
101+
Protocol::HTTPS
102+
else
103+
Protocol::HTTP
104+
end
105+
end
106+
end
107+
108+
def default_port
109+
secure? ? 443 : 80
110+
end
111+
112+
def default_port?
113+
port == default_port
114+
end
115+
116+
def port
117+
@options[:port] || @url.port || default_port
118+
end
119+
120+
# The hostname is the server we are connecting to:
121+
def hostname
122+
@options[:hostname] || @url.hostname
123+
end
124+
125+
def scheme
126+
@options[:scheme] || @url.scheme
127+
end
128+
129+
def authority(ignore_default_port = true)
130+
if ignore_default_port and default_port?
131+
@url.hostname
132+
else
133+
"#{@url.hostname}:#{port}"
134+
end
135+
end
136+
137+
# Return the path and query components of the given URL.
138+
def path
139+
buffer = @url.path || "/"
140+
141+
if query = @url.query
142+
buffer = "#{buffer}?#{query}"
143+
end
144+
145+
return buffer
146+
end
147+
148+
def alpn_protocols
149+
@options.fetch(:alpn_protocols) {self.protocol.names}
150+
end
151+
152+
def localhost?
153+
@url.hostname =~ /^(.*?\.)?localhost\.?$/
154+
end
155+
156+
# We don't try to validate peer certificates when talking to localhost because they would always be self-signed.
157+
def ssl_verify_mode
158+
if self.localhost?
159+
OpenSSL::SSL::VERIFY_NONE
160+
else
161+
OpenSSL::SSL::VERIFY_PEER
162+
end
163+
end
164+
165+
def ssl_context
166+
@options[:ssl_context] || OpenSSL::SSL::SSLContext.new.tap do |context|
167+
if alpn_protocols = self.alpn_protocols
168+
context.alpn_protocols = alpn_protocols
169+
end
170+
171+
context.set_params(
172+
verify_mode: self.ssl_verify_mode
173+
)
174+
end
175+
end
176+
177+
def build_endpoint(endpoint = nil)
178+
endpoint ||= tcp_endpoint
179+
180+
if secure?
181+
# Wrap it in SSL:
182+
return ::IO::Endpoint::SSLEndpoint.new(endpoint,
183+
ssl_context: self.ssl_context,
184+
hostname: @url.hostname,
185+
timeout: self.timeout,
186+
)
187+
end
188+
189+
return endpoint
190+
end
191+
192+
def endpoint
193+
@endpoint ||= build_endpoint
194+
end
195+
196+
def endpoint=(endpoint)
197+
@endpoint = build_endpoint(endpoint)
198+
end
199+
200+
def bind(*arguments, &block)
201+
endpoint.bind(*arguments, &block)
202+
end
203+
204+
def connect(&block)
205+
endpoint.connect(&block)
206+
end
207+
208+
def each
209+
return to_enum unless block_given?
210+
211+
self.tcp_endpoint.each do |endpoint|
212+
yield self.class.new(@url, endpoint, **@options)
213+
end
214+
end
215+
216+
def key
217+
[@url, @options]
218+
end
219+
220+
def eql? other
221+
self.key.eql? other.key
222+
end
223+
224+
def hash
225+
self.key.hash
226+
end
227+
228+
protected
229+
230+
def tcp_options
231+
options = @options.dup
232+
233+
options.delete(:scheme)
234+
options.delete(:port)
235+
options.delete(:hostname)
236+
options.delete(:ssl_context)
237+
options.delete(:alpn_protocols)
238+
options.delete(:protocol)
239+
240+
return options
241+
end
242+
243+
def tcp_endpoint
244+
::IO::Endpoint.tcp(self.hostname, port, **tcp_options)
245+
end
246+
end
247+
end
248+
end

0 commit comments

Comments
 (0)