spinel-spinner 0.1.0.pre.1

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.
@@ -0,0 +1,183 @@
1
+ module Spinner
2
+ class RequestError < StandardError
3
+ attr_reader :status
4
+
5
+ def initialize(status, message)
6
+ super(message)
7
+ @status = status
8
+ end
9
+ end
10
+
11
+ # Incremental framing stays separate from socket reads and application code.
12
+ class RequestParser
13
+ MAX_BODY_BYTES = 1048576
14
+ SUPPORTED_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
15
+
16
+ attr_reader :method, :path, :query, :headers, :remainder, :content_length, :protocol
17
+
18
+ def initialize(max_header_bytes = 32768, server_port = "4000", scheme = "http", max_body_bytes = MAX_BODY_BYTES)
19
+ @max_body_bytes = max_body_bytes
20
+ @max_header_bytes = max_header_bytes
21
+ @buffer = "".b
22
+ @complete = false
23
+ @method = ""
24
+ @path = ""
25
+ @query = ""
26
+ @headers = {}
27
+ @remainder = "".b
28
+ @content_length = 0
29
+ @chunked = false
30
+ @expect_continue = false
31
+ @server_name = ""
32
+ @server_port = server_port
33
+ @scheme = scheme
34
+ @protocol = ""
35
+ @http_headers = {}
36
+ end
37
+
38
+ def complete?
39
+ @complete
40
+ end
41
+
42
+ def chunked?
43
+ @chunked
44
+ end
45
+
46
+ def expect_continue?
47
+ @expect_continue
48
+ end
49
+
50
+ def keep_alive?
51
+ tokens = @headers.fetch("connection", "").downcase.split(",").map(&:strip)
52
+ return false if tokens.include?("close")
53
+ @protocol == "HTTP/1.1" || tokens.include?("keep-alive")
54
+ end
55
+
56
+ def feed(bytes)
57
+ raise RequestError.new(400, "request already complete") if @complete
58
+ @buffer += bytes
59
+ boundary = @buffer.index("\r\n\r\n")
60
+ if boundary.nil?
61
+ raise RequestError.new(431, "headers too large") if @buffer.bytesize > @max_header_bytes
62
+ return false
63
+ end
64
+ size = boundary + 4
65
+ raise RequestError.new(431, "headers too large") if size > @max_header_bytes
66
+ head = @buffer.byteslice(0, boundary)
67
+ @remainder = @buffer.byteslice(size, @buffer.bytesize - size)
68
+ parse(head)
69
+ @buffer = "".b
70
+ @complete = true
71
+ true
72
+ end
73
+
74
+ def environment(body = "".b)
75
+ raise RequestError.new(400, "incomplete headers") unless @complete
76
+ env = {"REQUEST_METHOD" => @method, "SCRIPT_NAME" => "", "PATH_INFO" => @path,
77
+ "QUERY_STRING" => @query, "SERVER_NAME" => @server_name,
78
+ "SERVER_PORT" => @server_port, "SERVER_PROTOCOL" => @protocol,
79
+ "rack.url_scheme" => @scheme, "rack.input" => (body.is_a?(Input) ? body : Input.new(body))}
80
+ env["CONTENT_LENGTH"] = @headers["content-length"] if @headers.key?("content-length")
81
+ env["CONTENT_TYPE"] = @headers["content-type"] if @headers.key?("content-type")
82
+ @http_headers.each { |key, value| env[key] = value }
83
+ env
84
+ end
85
+
86
+ def parse(head)
87
+ lines = head.split("\r\n")
88
+ request = lines.shift.to_s
89
+ unless request.match?(/\A[!#$%&'*+.^_`|~0-9A-Za-z-]+ [^\x00-\x20\x7f-\xff]+ HTTP\/1\.[01]\z/n)
90
+ raise RequestError.new(400, "invalid request line")
91
+ end
92
+ parts = request.split(" ")
93
+ @method = parts[0]
94
+ @protocol = parts[2]
95
+ target = parts[1]
96
+ raise RequestError.new(400, "invalid request target") if target.include?("#")
97
+ @target_authority = nil
98
+ @target_scheme = nil
99
+ if target.start_with?("http://") || target.start_with?("https://")
100
+ match = target.match(/\A(https?):\/\/([^\/\?]+)(.*)\z/)
101
+ raise RequestError.new(400, "invalid absolute target") if match.nil?
102
+ @target_scheme = match[1]
103
+ @target_authority = match[2]
104
+ HostAuthority.new(@target_authority)
105
+ target = match[3]
106
+ target = "/" + target if target.empty? || target.start_with?("?")
107
+ end
108
+ raise RequestError.new(405, "unsupported method") if @method == "CONNECT"
109
+ unless target.start_with?("/") || (target == "*" && @method == "OPTIONS")
110
+ raise RequestError.new(400, "unsupported request target")
111
+ end
112
+ path_and_query = target.split("?", 2)
113
+ @path = path_and_query[0]
114
+ @query = path_and_query.length == 2 ? path_and_query[1] : ""
115
+
116
+ normalized_names = {}
117
+ lines.each do |line|
118
+ pair = line.split(":", 2)
119
+ unless pair.length == 2 && pair[0].match?(/\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/)
120
+ raise RequestError.new(400, "invalid header")
121
+ end
122
+ name = pair[0].downcase
123
+ value = pair[1].strip
124
+ if pair[1].match?(/[\x00-\x08\x0a-\x1f\x7f]/)
125
+ raise RequestError.new(400, "invalid or duplicate header")
126
+ end
127
+ normalized = name.upcase.tr("-", "_")
128
+ if normalized_names.key?(normalized) && normalized_names[normalized] != name
129
+ raise RequestError.new(400, "ambiguous header normalization")
130
+ end
131
+ if (normalized == "CONTENT_TYPE" && name != "content-type") ||
132
+ (normalized == "CONTENT_LENGTH" && name != "content-length")
133
+ raise RequestError.new(400, "ambiguous content header")
134
+ end
135
+ normalized_names[normalized] = name
136
+ if @headers.key?(name)
137
+ if ["host", "content-length", "transfer-encoding", "content-type", "expect", "authorization", "sec-websocket-key", "sec-websocket-version"].include?(name)
138
+ raise RequestError.new(400, "duplicate singleton header")
139
+ end
140
+ value = @headers[name] + (name == "cookie" ? "; " : ", ") + value
141
+ end
142
+ @headers[name] = value
143
+ unless name == "content-type" || name == "content-length"
144
+ @http_headers["HTTP_#{normalized}"] = value
145
+ end
146
+ end
147
+ host = @headers.fetch("host", "")
148
+ if host.empty? && @protocol == "HTTP/1.0"
149
+ @server_name = "localhost"
150
+ else
151
+ @server_name = HostAuthority.new(host).name
152
+ end
153
+ unless @target_authority.nil?
154
+ default_port = @target_scheme == "https" ? "443" : "80"
155
+ canonical_target = @target_authority.downcase.delete_suffix(":" + default_port)
156
+ canonical_host = host.downcase.delete_suffix(":" + default_port)
157
+ raise RequestError.new(400, "conflicting target authority") unless canonical_target == canonical_host
158
+ raise RequestError.new(400, "conflicting target scheme") unless @target_scheme == @scheme
159
+ end
160
+ if @headers.key?("transfer-encoding")
161
+ raise RequestError.new(400, "HTTP/1.0 transfer encoding unsupported") if @protocol != "HTTP/1.1"
162
+ raise RequestError.new(400, "ambiguous message framing") if @headers.key?("content-length")
163
+ raise RequestError.new(400, "unsupported transfer encoding") unless @headers["transfer-encoding"].downcase == "chunked"
164
+ @chunked = true
165
+ end
166
+ length = @headers.fetch("content-length", "0")
167
+ raise RequestError.new(400, "invalid Content-Length") unless length.match?(/\A[0-9]+\z/)
168
+ # Compare decimal strings before conversion; even enormous valid lengths
169
+ # must be rejected without overflowing the compiler's integer type.
170
+ digits = length.sub(/\A0+/, "")
171
+ limit = @max_body_bytes.to_s
172
+ if digits.length > limit.length || (digits.length == limit.length && digits > limit)
173
+ raise RequestError.new(413, "request body exceeds configured limit")
174
+ end
175
+ @content_length = digits.empty? ? 0 : digits.to_i
176
+ raise RequestError.new(405, "unsupported method") unless SUPPORTED_METHODS.include?(@method)
177
+ if @headers.key?("expect")
178
+ raise RequestError.new(417, "expectations unsupported") unless @headers["expect"].downcase == "100-continue" && @protocol == "HTTP/1.1"
179
+ @expect_continue = true
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,123 @@
1
+ module Spinner
2
+ class ResponseWriter
3
+ def upgrade_header(response)
4
+ status, headers, body = response
5
+ raise ArgumentError, "expected upgrade response" unless status == 101 && body.is_a?(Upgrade)
6
+ unless headers.is_a?(Hash) && headers["upgrade"].is_a?(String) && headers["upgrade"].match?(/\A[!#$%&'*+.^_`|~0-9A-Za-z-]+(?:\/[!#$%&'*+.^_`|~0-9A-Za-z-]+)?\z/)
7
+ raise ArgumentError, "expected upgrade protocol"
8
+ end
9
+ # Reuse header validation, replacing only server-owned connection framing.
10
+ head = header(200, headers, nil, false, "HTTP/1.1", false)
11
+ head.sub("200 OK", "101 Switching Protocols").sub("connection: close", "connection: Upgrade")
12
+ end
13
+
14
+ def self.reason(status)
15
+ reasons = {200 => "OK", 201 => "Created", 202 => "Accepted", 204 => "No Content",
16
+ 205 => "Reset Content", 301 => "Moved Permanently", 302 => "Found",
17
+ 304 => "Not Modified", 400 => "Bad Request", 404 => "Not Found",
18
+ 405 => "Method Not Allowed", 408 => "Request Timeout",
19
+ 413 => "Content Too Large", 417 => "Expectation Failed",
20
+ 431 => "Request Header Fields Too Large", 500 => "Internal Server Error",
21
+ 503 => "Service Unavailable"}
22
+ reasons.fetch(status, "Response")
23
+ end
24
+
25
+ # Validate and serialize before any bytes reach the client.
26
+ def prepare(response, keep_alive = false, protocol = "HTTP/1.1", head_request = false)
27
+ unless response.is_a?(Array) && response.length == 3
28
+ raise ArgumentError, "expected [status, headers, body]"
29
+ end
30
+ status, headers, body = response
31
+ unless status.is_a?(Integer) && status >= 200 && status <= 599
32
+ raise ArgumentError, "expected a final HTTP status"
33
+ end
34
+ raise ArgumentError, "expected header hash" unless headers.is_a?(Hash)
35
+ raise ArgumentError, "expected body array" unless body.is_a?(Array)
36
+ length = 0
37
+ body.each do |chunk|
38
+ raise ArgumentError, "expected string body chunks" unless chunk.is_a?(String)
39
+ length += chunk.bytesize
40
+ end
41
+ if [204, 205, 304].include?(status) && length != 0
42
+ raise ArgumentError, "status requires an empty body"
43
+ end
44
+ head = header(status, headers, length, keep_alive, protocol, false)
45
+ chunks = [head]
46
+ body.each { |chunk| chunks << chunk } unless head_request
47
+ chunks
48
+ end
49
+
50
+ def prepare_stream(response, keep_alive = false, protocol = "HTTP/1.1")
51
+ unless response.is_a?(Array) && response.length == 3
52
+ raise ArgumentError, "expected [status, headers, body]"
53
+ end
54
+ status, headers, body = response
55
+ raise ArgumentError, "expected enumerable body" unless body.respond_to?(:each)
56
+ raise ArgumentError, "bodyless status cannot stream" if [204, 205, 304].include?(status)
57
+ chunked = protocol == "HTTP/1.1"
58
+ [header(status, headers, nil, keep_alive && chunked, protocol, chunked), chunked]
59
+ end
60
+
61
+ def header(status, headers, length, keep_alive, protocol, chunked)
62
+ unless status.is_a?(Integer) && status >= 200 && status <= 599
63
+ raise ArgumentError, "expected a final HTTP status"
64
+ end
65
+ raise ArgumentError, "expected header hash" unless headers.is_a?(Hash)
66
+ head = "#{protocol} #{status} #{ResponseWriter.reason(status)}\r\n"
67
+ headers.each do |name, value|
68
+ unless name.is_a?(String) && name.match?(/\A[!#$%&'*+.^_`|~0-9a-z-]+\z/)
69
+ raise ArgumentError, "expected lowercase header names"
70
+ end
71
+ values = value.is_a?(Array) ? value : [value]
72
+ values.each do |field|
73
+ raise ArgumentError, "invalid header value" unless field.is_a?(String) && !field.match?(/[\x00-\x08\x0a-\x1f\x7f]/)
74
+ end
75
+ raise ArgumentError, "transfer encoding unsupported" if name == "transfer-encoding"
76
+ next if name == "content-length" || name == "connection"
77
+ values.each { |field| head += "#{name}: #{field}\r\n" }
78
+ end
79
+ head += "transfer-encoding: chunked\r\n" if chunked
80
+ head += "content-length: #{length}\r\n" if !length.nil? && status != 204 && status != 304
81
+ head += "allow: #{RequestParser::SUPPORTED_METHODS.join(', ')}\r\n" if status == 405 && !headers.key?("allow")
82
+ head += "connection: #{keep_alive ? 'keep-alive' : 'close'}\r\n\r\n"
83
+ head
84
+ end
85
+
86
+ def write_stream(transport, response, deadline, head, chunked, head_request = false, idle_timeout = 30.0, client = nil)
87
+ body = response[2]
88
+ begin
89
+ write(transport, [head], deadline)
90
+ unless head_request
91
+ body.each do |part|
92
+ raise ArgumentError, "expected string body chunks" unless part.is_a?(String)
93
+ active_deadline = client.nil? ? deadline : client.stream_deadline
94
+ raise IOError, "stream idle timeout" if Transport.now >= active_deadline
95
+ next if part.empty?
96
+ deadline = Transport.now + idle_timeout
97
+ client.stream_deadline = deadline unless client.nil?
98
+ if chunked
99
+ write(transport, ["#{part.bytesize.to_s(16)}\r\n", part, "\r\n"], deadline)
100
+ else
101
+ write(transport, [part], deadline)
102
+ end
103
+ end
104
+ write(transport, ["0\r\n\r\n"], deadline) if chunked
105
+ end
106
+ ensure
107
+ body.close if body.respond_to?(:close)
108
+ end
109
+ end
110
+
111
+ def write(transport, chunks, deadline)
112
+ chunks.each do |chunk|
113
+ offset = 0
114
+ while offset < chunk.bytesize
115
+ remaining = chunk.byteslice(offset, chunk.bytesize - offset)
116
+ count = transport.write_some(remaining, deadline)
117
+ raise IOError, "invalid write progress" if count <= 0 || count > remaining.bytesize
118
+ offset += count
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end