http-protocol 0.3.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84fb025f5c2cc394607d0c28b592c8fda12285a8340c0d3eb2a5ec8328aab371
4
- data.tar.gz: 98a7233fe2c8e0f706d9738b0845bd86a9edd7e4fadf4335fc30763a3a468927
3
+ metadata.gz: 671f05047ee2dfd7365a45589f83f27a0f097c6e0cb787b61e3569863c0c5db4
4
+ data.tar.gz: fb4f399f497fd3480398522d2b7256800c04e99c944d0a2e00400335811c53b8
5
5
  SHA512:
6
- metadata.gz: 5a436986f8203efcc943a7353acb3a8c9489f5e5576127b913482b684f4d879de6f4b88b05c9b17bd5c981826177be00b7f1f2876a6239ee0a5b7c17763afc84
7
- data.tar.gz: 4453a7d3c4cab53a2ba6fdb1db9eb82988157582cd9c01b8aca9dd437d13cd6adfc4b20e1e03065cfc0150c497f67f8a05de3d1706d0ec932519e291227216a2
6
+ metadata.gz: 8b1c95fdac9a51fbbd9a285dd0619e7d463f668e85e5a5a4329e0eee5abd18e96cb911ff9311796d14ac36e8515f3803acd4758e73112e4f857cc60bd73b9e60
7
+ data.tar.gz: ac491a301fe51695e6ac1efae9fe1e73fe13d4b9ad8eac87f43753d60107fbd57e20e5589422e25a7496849d793205cab37db4e501657a7cd82b3deca26496fc
data/README.md CHANGED
@@ -23,11 +23,11 @@ Or install it yourself as:
23
23
  ### HTTP2
24
24
 
25
25
  ```ruby
26
- framer = HTTP::Protocol::HTTP2::Framer.new(io)
26
+ framer = HTTP::Protocol::HTTP2::Framer.new(stream)
27
27
 
28
28
  frame = framer.read_frame
29
29
 
30
- frame.write(io)
30
+ frame.write(stream)
31
31
  ```
32
32
 
33
33
  ## Contributing
@@ -20,6 +20,10 @@
20
20
 
21
21
  module HTTP
22
22
  module Protocol
23
+ TRANSFER_ENCODING = 'transfer-encoding'.freeze
24
+ CONTENT_LENGTH = 'content-length'.freeze
25
+ CHUNKED = 'chunked'.freeze
26
+
23
27
  class Headers
24
28
  class Split < Array
25
29
  COMMA = /\s*,\s*/
@@ -0,0 +1,53 @@
1
+ # Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require_relative '../http11/connection'
22
+
23
+ module HTTP
24
+ module Protocol
25
+ module HTTP10
26
+ class Connection < HTTP11::Connection
27
+ KEEP_ALIVE = 'keep-alive'.freeze
28
+ VERSION = "HTTP/1.0".freeze
29
+
30
+ def version
31
+ VERSION
32
+ end
33
+
34
+ def persistent?(headers)
35
+ if connection = headers[CONNECTION]
36
+ return connection.include?(KEEP_ALIVE)
37
+ else
38
+ return false
39
+ end
40
+ end
41
+
42
+ def write_persistent_header
43
+ @stream.write("connection: keep-alive\r\n") if @persistent
44
+ end
45
+
46
+ def write_body(body, chunked = false)
47
+ # We don't support chunked encoding by default.
48
+ super(body, chunked)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,395 @@
1
+ # Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require_relative '../error'
22
+
23
+ module HTTP
24
+ module Protocol
25
+ module HTTP11
26
+ class Connection
27
+ CRLF = "\r\n".freeze
28
+ CONNECTION = 'connection'.freeze
29
+ HOST = 'host'.freeze
30
+ CLOSE = 'close'.freeze
31
+ VERSION = "HTTP/1.1".freeze
32
+
33
+ def initialize(stream, persistent = true)
34
+ @stream = stream
35
+
36
+ @persistent = persistent
37
+
38
+ @count = 0
39
+ end
40
+
41
+ attr :stream
42
+ attr :persistent
43
+ attr :count
44
+
45
+ def version
46
+ VERSION
47
+ end
48
+
49
+ def persistent?(headers)
50
+ if connection = headers[CONNECTION]
51
+ return !connection.include?(CLOSE)
52
+ else
53
+ return true
54
+ end
55
+ end
56
+
57
+ # Effectively close the connection and return the underlying IO.
58
+ # @return [IO] the underlying non-blocking IO.
59
+ def hijack
60
+ @persistent = false
61
+
62
+ @stream.flush
63
+
64
+ return @stream
65
+ end
66
+
67
+ # Write the appropriate header for connection persistence.
68
+ def write_persistent_header
69
+ @stream.write("connection: keep-alive\r\n") if @persistent
70
+ end
71
+
72
+ # Close the connection and underlying stream.
73
+ def close
74
+ @stream.close
75
+ end
76
+
77
+ def write_request(authority, method, path, version, headers)
78
+ @stream.write("#{method} #{path} #{version}\r\n")
79
+ @stream.write("host: #{authority}\r\n")
80
+
81
+ write_headers(headers)
82
+
83
+ @stream.flush
84
+ end
85
+
86
+ def write_response(version, status, headers, body = nil, head = false)
87
+ @stream.write("#{version} #{status}\r\n")
88
+ write_headers(headers)
89
+
90
+ if head
91
+ write_body_head(body)
92
+ else
93
+ write_body(body)
94
+ end
95
+
96
+ @stream.flush
97
+ end
98
+
99
+ def write_headers(headers)
100
+ headers.each do |name, value|
101
+ @stream.write("#{name}: #{value}\r\n")
102
+ end
103
+ end
104
+
105
+ def each_line
106
+ while line = read_line
107
+ yield line
108
+ end
109
+ end
110
+
111
+ def read_line
112
+ # To support Ruby 2.3, we do the following which is pretty inefficient. Ruby 2.4+ can do the following:
113
+ # @stream.gets(CRLF, chomp: true) or raise EOFError
114
+ if line = @stream.gets(CRLF)
115
+ return line.chomp!(CRLF)
116
+ else
117
+ raise EOFError
118
+ end
119
+ end
120
+
121
+ def read_request
122
+ method, path, version = read_line.split(/\s+/, 3)
123
+ headers = read_headers
124
+
125
+ @persistent = persistent?(headers)
126
+
127
+ body = read_request_body(headers)
128
+
129
+ @count += 1
130
+
131
+ return headers[HOST], method, path, version, headers, body
132
+ end
133
+
134
+ def read_response(method)
135
+ version, status, reason = read_line.split(/\s+/, 3)
136
+ Async.logger.debug(self) {"#{version} #{status} #{reason}"}
137
+
138
+ headers = read_headers
139
+
140
+ @persistent = persistent?(headers)
141
+
142
+ body = read_response_body(method, status, headers)
143
+
144
+ @count += 1
145
+
146
+ return version, Integer(status), reason, headers, body
147
+ end
148
+
149
+ def read_headers
150
+ fields = []
151
+
152
+ self.each_line do |line|
153
+ if line =~ /^([a-zA-Z\-\d]+):\s*(.+?)\s*$/
154
+ fields << [$1, $2]
155
+ else
156
+ break
157
+ end
158
+ end
159
+
160
+ return Headers.new(fields)
161
+ end
162
+
163
+ def read_chunk
164
+ length = self.read_line.to_i(16)
165
+
166
+ if length == 0
167
+ self.read_line
168
+
169
+ return nil
170
+ end
171
+
172
+ # Read the data:
173
+ chunk = @stream.read(length)
174
+
175
+ # Consume the trailing CRLF:
176
+ crlf = @stream.read(2)
177
+
178
+ return chunk
179
+ end
180
+
181
+ def write_chunk(chunk)
182
+ if chunk.nil?
183
+ @stream.write("0\r\n\r\n")
184
+ elsif !chunk.empty?
185
+ @stream.write("#{chunk.bytesize.to_s(16).upcase}\r\n")
186
+ @stream.write(chunk)
187
+ @stream.write(CRLF)
188
+ @stream.flush
189
+ end
190
+ end
191
+
192
+ def write_empty_body(body)
193
+ # Write empty body:
194
+ write_persistent_header
195
+ @stream.write("content-length: 0\r\n\r\n")
196
+
197
+ body.read if body
198
+
199
+ @stream.flush
200
+ end
201
+
202
+ def write_fixed_length_body(body, length)
203
+ write_persistent_header
204
+ @stream.write("content-length: #{length}\r\n\r\n")
205
+
206
+ chunk_length = 0
207
+ body.each do |chunk|
208
+ chunk_length += chunk.bytesize
209
+
210
+ if chunk_length > length
211
+ raise ArgumentError, "Trying to write #{chunk_length} bytes, but content length was #{length} bytes!"
212
+ end
213
+
214
+ @stream.write(chunk)
215
+ end
216
+
217
+ @stream.flush
218
+
219
+ if chunk_length != length
220
+ raise ArgumentError, "Wrote #{chunk_length} bytes, but content length was #{length} bytes!"
221
+ end
222
+ end
223
+
224
+ def write_chunked_body(body)
225
+ write_persistent_header
226
+ @stream.write("transfer-encoding: chunked\r\n\r\n")
227
+
228
+ body.each do |chunk|
229
+ next if chunk.size == 0
230
+
231
+ @stream.write("#{chunk.bytesize.to_s(16).upcase}\r\n")
232
+ @stream.write(chunk)
233
+ @stream.write(CRLF)
234
+ @stream.flush
235
+ end
236
+
237
+ @stream.write("0\r\n\r\n")
238
+ @stream.flush
239
+ end
240
+
241
+ def write_body_and_close(body)
242
+ # We can't be persistent because we don't know the data length:
243
+ @persistent = false
244
+ write_persistent_header
245
+
246
+ @stream.write("\r\n")
247
+
248
+ body.each do |chunk|
249
+ @stream.write(chunk)
250
+ @stream.flush
251
+ end
252
+
253
+ @stream.stream.close_write
254
+ end
255
+
256
+ def write_body(body, chunked = true)
257
+ if body.nil? or body.empty?
258
+ write_empty_body(body)
259
+ elsif length = body.length
260
+ write_fixed_length_body(body, length)
261
+ elsif chunked
262
+ write_chunked_body(body)
263
+ else
264
+ write_body_and_close(body)
265
+ end
266
+ end
267
+
268
+ def write_body_head(body)
269
+ write_persistent_header
270
+
271
+ if body.nil? or body.empty?
272
+ @stream.write("content-length: 0\r\n\r\n")
273
+ elsif length = body.length
274
+ @stream.write("content-length: #{length}\r\n\r\n")
275
+ else
276
+ @stream.write("\r\n")
277
+ end
278
+ end
279
+
280
+ def read_chunked_body
281
+ buffer = String.new.b
282
+
283
+ while chunk = read_chunk
284
+ buffer << chunk
285
+ chunk.clear
286
+ end
287
+
288
+ return buffer
289
+ end
290
+
291
+ def read_fixed_body(length)
292
+ @stream.read(length)
293
+ end
294
+
295
+ def read_tunnel_body
296
+ read_remainder_body
297
+ end
298
+
299
+ def read_remainder_body
300
+ @stream.read
301
+ end
302
+
303
+ HEAD = "HEAD".freeze
304
+ CONNECT = "CONNECT".freeze
305
+
306
+ def read_response_body(method, status, headers)
307
+ # RFC 7230 3.3.3
308
+ # 1. Any response to a HEAD request and any response with a 1xx
309
+ # (Informational), 204 (No Content), or 304 (Not Modified) status
310
+ # code is always terminated by the first empty line after the
311
+ # header fields, regardless of the header fields present in the
312
+ # message, and thus cannot contain a message body.
313
+ if method == "HEAD" or status == 204 or status == 304
314
+ return nil
315
+ end
316
+
317
+ # 2. Any 2xx (Successful) response to a CONNECT request implies that
318
+ # the connection will become a tunnel immediately after the empty
319
+ # line that concludes the header fields. A client MUST ignore any
320
+ # Content-Length or Transfer-Encoding header fields received in
321
+ # such a message.
322
+ if method == "CONNECT" and status == 200
323
+ return read_tunnel_body
324
+ end
325
+
326
+ if body = read_body(headers)
327
+ return body
328
+ else
329
+ # 7. Otherwise, this is a response message without a declared message
330
+ # body length, so the message body length is determined by the
331
+ # number of octets received prior to the server closing the
332
+ # connection.
333
+ return read_remainder_body
334
+ end
335
+ end
336
+
337
+ def read_request_body(headers)
338
+ # 6. If this is a request message and none of the above are true, then
339
+ # the message body length is zero (no message body is present).
340
+ if body = read_body(headers)
341
+ return body
342
+ end
343
+ end
344
+
345
+ def read_body(headers)
346
+ # 3. If a Transfer-Encoding header field is present and the chunked
347
+ # transfer coding (Section 4.1) is the final encoding, the message
348
+ # body length is determined by reading and decoding the chunked
349
+ # data until the transfer coding indicates the data is complete.
350
+ if transfer_encoding = headers[TRANSFER_ENCODING]
351
+ # If a message is received with both a Transfer-Encoding and a
352
+ # Content-Length header field, the Transfer-Encoding overrides the
353
+ # Content-Length. Such a message might indicate an attempt to
354
+ # perform request smuggling (Section 9.5) or response splitting
355
+ # (Section 9.4) and ought to be handled as an error. A sender MUST
356
+ # remove the received Content-Length field prior to forwarding such
357
+ # a message downstream.
358
+ if headers[CONTENT_LENGTH]
359
+ raise BadRequest, "Message contains both transfer encoding and content length!"
360
+ end
361
+
362
+ if transfer_encoding.last == CHUNKED
363
+ return read_chunked_body
364
+ else
365
+ # If a Transfer-Encoding header field is present in a response and
366
+ # the chunked transfer coding is not the final encoding, the
367
+ # message body length is determined by reading the connection until
368
+ # it is closed by the server. If a Transfer-Encoding header field
369
+ # is present in a request and the chunked transfer coding is not
370
+ # the final encoding, the message body length cannot be determined
371
+ # reliably; the server MUST respond with the 400 (Bad Request)
372
+ # status code and then close the connection.
373
+ return read_body_remainder
374
+ end
375
+ end
376
+
377
+ # 5. If a valid Content-Length header field is present without
378
+ # Transfer-Encoding, its decimal value defines the expected message
379
+ # body length in octets. If the sender closes the connection or
380
+ # the recipient times out before the indicated number of octets are
381
+ # received, the recipient MUST consider the message to be
382
+ # incomplete and close the connection.
383
+ if content_length = headers[CONTENT_LENGTH]
384
+ length = Integer(content_length)
385
+ if length >= 0
386
+ return read_fixed_body(length)
387
+ else
388
+ raise BadRequest, "Invalid content length: #{content_length}"
389
+ end
390
+ end
391
+ end
392
+ end
393
+ end
394
+ end
395
+ end
@@ -34,21 +34,21 @@ module HTTP
34
34
  flag_set?(END_HEADERS)
35
35
  end
36
36
 
37
- def read(io, maximum_frame_size)
37
+ def read(stream, maximum_frame_size)
38
38
  super
39
39
 
40
40
  unless end_headers?
41
41
  @continuation = ContinuationFrame.new
42
42
 
43
- @continuation.read(io, maximum_frame_size)
43
+ @continuation.read(stream, maximum_frame_size)
44
44
  end
45
45
  end
46
46
 
47
- def write(io)
47
+ def write(stream)
48
48
  super
49
49
 
50
50
  if continuation = self.continuation
51
- continuation.write(io)
51
+ continuation.write(stream)
52
52
  end
53
53
  end
54
54
 
@@ -36,7 +36,7 @@ module HTTP
36
36
  include Comparable
37
37
 
38
38
  # Stream Identifier cannot be bigger than this:
39
- # https://http2.github.io/http2-spec/#rfc.section.4.1
39
+ # https://http2.github.stream/http2-spec/#rfc.section.4.1
40
40
  VALID_STREAM_ID = 0..0x7fffffff
41
41
 
42
42
  # The absolute maximum bounds for the length field:
@@ -151,39 +151,39 @@ module HTTP
151
151
  return length, type, flags, stream_id
152
152
  end
153
153
 
154
- def read_header(io)
155
- @length, @type, @flags, @stream_id = Frame.parse_header(io.read(9))
154
+ def read_header(stream)
155
+ @length, @type, @flags, @stream_id = Frame.parse_header(stream.read(9))
156
156
  end
157
157
 
158
- def read_payload(io)
159
- @payload = io.read(@length)
158
+ def read_payload(stream)
159
+ @payload = stream.read(@length)
160
160
  end
161
161
 
162
- def read(io, maximum_frame_size = MAXIMUM_ALLOWED_FRAME_SIZE)
163
- read_header(io) unless @length
162
+ def read(stream, maximum_frame_size = MAXIMUM_ALLOWED_FRAME_SIZE)
163
+ read_header(stream) unless @length
164
164
 
165
165
  if @length > maximum_frame_size
166
166
  raise FrameSizeError, "Frame length #{@length} exceeds maximum frame size #{maximum_frame_size}!"
167
167
  end
168
168
 
169
- read_payload(io)
169
+ read_payload(stream)
170
170
  end
171
171
 
172
- def write_header(io)
173
- io.write self.header
172
+ def write_header(stream)
173
+ stream.write self.header
174
174
  end
175
175
 
176
- def write_payload(io)
177
- io.write(@payload) if @payload
176
+ def write_payload(stream)
177
+ stream.write(@payload) if @payload
178
178
  end
179
179
 
180
- def write(io)
180
+ def write(stream)
181
181
  if @payload and @length != @payload.bytesize
182
182
  raise ProtocolError, "Invalid payload size: #{@length} != #{@payload.bytesize}"
183
183
  end
184
184
 
185
- self.write_header(io)
186
- self.write_payload(io)
185
+ self.write_header(stream)
186
+ self.write_payload(stream)
187
187
  end
188
188
 
189
189
  def apply(connection)
@@ -53,23 +53,21 @@ module HTTP
53
53
  CONNECTION_PREFACE_MAGIC = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".freeze
54
54
 
55
55
  class Framer
56
- def initialize(io, frames = FRAMES)
57
- @io = io
56
+ def initialize(stream, frames = FRAMES)
57
+ @stream = stream
58
58
  @frames = frames
59
-
60
- @buffer = String.new.b
61
59
  end
62
60
 
63
61
  def close
64
- @io.close
62
+ @stream.close
65
63
  end
66
64
 
67
65
  def write_connection_preface
68
- @io.write(CONNECTION_PREFACE_MAGIC)
66
+ @stream.write(CONNECTION_PREFACE_MAGIC)
69
67
  end
70
68
 
71
69
  def read_connection_preface
72
- string = @io.read(CONNECTION_PREFACE_MAGIC.bytesize)
70
+ string = @stream.read(CONNECTION_PREFACE_MAGIC.bytesize)
73
71
 
74
72
  unless string == CONNECTION_PREFACE_MAGIC
75
73
  raise ProtocolError, "Invalid connection preface: #{string.inspect}"
@@ -89,20 +87,20 @@ module HTTP
89
87
  frame = klass.new(stream_id, flags, type, length)
90
88
 
91
89
  # Read the payload:
92
- frame.read(@io, maximum_frame_size)
90
+ frame.read(@stream, maximum_frame_size)
93
91
 
94
92
  return frame
95
93
  end
96
94
 
97
95
  def write_frame(frame)
98
96
  # puts "framer: write_frame #{frame.inspect}"
99
- frame.write(@io)
97
+ frame.write(@stream)
100
98
 
101
- @io.flush
99
+ @stream.flush
102
100
  end
103
101
 
104
102
  def read_header
105
- if buffer = @io.read(9)
103
+ if buffer = @stream.read(9)
106
104
  return Frame.parse_header(buffer)
107
105
  else
108
106
  # TODO: Is this necessary? I thought the IO would throw this.
@@ -69,7 +69,7 @@ module HTTP
69
69
  return frame
70
70
  end
71
71
 
72
- def read_payload(io)
72
+ def read_payload(stream)
73
73
  super
74
74
 
75
75
  if @length > 8
@@ -69,7 +69,7 @@ module HTTP
69
69
  connection.receive_priority(self)
70
70
  end
71
71
 
72
- def read_payload(io)
72
+ def read_payload(stream)
73
73
  super
74
74
 
75
75
  if @length != 5
@@ -61,7 +61,7 @@ module HTTP
61
61
  connection.receive_reset_stream(self)
62
62
  end
63
63
 
64
- def read_payload(io)
64
+ def read_payload(stream)
65
65
  super
66
66
 
67
67
  if @length != 4
@@ -218,7 +218,7 @@ module HTTP
218
218
  connection.receive_settings(self)
219
219
  end
220
220
 
221
- def read_payload(io)
221
+ def read_payload(stream)
222
222
  super
223
223
 
224
224
  if @stream_id != 0
@@ -0,0 +1,188 @@
1
+ # Copyright, 2018, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ module HTTP
22
+ module Protocol
23
+ # A relative reference, excluding any authority.
24
+ class Reference
25
+ def initialize(path, query_string, fragment, parameters)
26
+ @path = path
27
+ @query_string = query_string
28
+ @fragment = fragment
29
+ @parameters = parameters
30
+ end
31
+
32
+ def self.[] reference
33
+ if reference.is_a? self
34
+ return reference
35
+ else
36
+ return self.parse(reference)
37
+ end
38
+ end
39
+
40
+ # Generate a reference from a path and user parameters. The path may contain a `#fragment` or `?query=parameters`.
41
+ def self.parse(path = '/', parameters = nil)
42
+ base, fragment = path.split('#', 2)
43
+ path, query_string = base.split('?', 2)
44
+
45
+ self.new(path, query_string, fragment, parameters)
46
+ end
47
+
48
+ # The path component, e.g. /foo/bar/index.html
49
+ attr :path
50
+
51
+ # The un-parsed query string, e.g. 'x=10&y=20'
52
+ attr :query_string
53
+
54
+ # A fragment, the part after the '#'
55
+ attr :fragment
56
+
57
+ # User supplied parameters that will be appended to the query part.
58
+ attr :parameters
59
+
60
+ def parameters?
61
+ @parameters and !@parameters.empty?
62
+ end
63
+
64
+ def query_string?
65
+ @query_string and !@query_string.empty?
66
+ end
67
+
68
+ def fragment?
69
+ @fragment and !@fragment.empty?
70
+ end
71
+
72
+ def append(buffer)
73
+ if query_string?
74
+ buffer << escape_path(@path) << '?' << @query_string
75
+ buffer << '&' << encode(@parameters) if parameters?
76
+ else
77
+ buffer << escape_path(@path)
78
+ buffer << '?' << encode(@parameters) if parameters?
79
+ end
80
+
81
+ if fragment?
82
+ buffer << '#' << escape(@fragment)
83
+ end
84
+
85
+ return buffer
86
+ end
87
+
88
+ def to_str
89
+ append(String.new)
90
+ end
91
+
92
+ alias to_s to_str
93
+
94
+ def + other
95
+ other = self.class[other]
96
+
97
+ self.class.new(
98
+ expand_path(self.path, other.path),
99
+ other.query_string,
100
+ other.fragment,
101
+ other.parameters,
102
+ )
103
+ end
104
+
105
+ def [] parameters
106
+ self.dup(nil, parameters)
107
+ end
108
+
109
+ def dup(path = nil, parameters = nil)
110
+ if @parameters
111
+ if parameters
112
+ parameters = @parameters.merge(parameters)
113
+ else
114
+ parameters = @parameters
115
+ end
116
+ end
117
+
118
+ if path
119
+ path = @path + '/' + path
120
+ else
121
+ path = @path
122
+ end
123
+
124
+ self.class.new(path, @query_string, @fragment, parameters)
125
+ end
126
+
127
+ private
128
+
129
+ def expand_path(base, relative)
130
+ if relative.start_with? '/'
131
+ return relative
132
+ else
133
+ path = base.split('/')
134
+ parts = relative.split('/')
135
+
136
+ parts.each do |part|
137
+ if part == '..'
138
+ path.pop
139
+ else
140
+ path << part
141
+ end
142
+ end
143
+
144
+ return path.join('/')
145
+ end
146
+ end
147
+
148
+ # Escapes a generic string, using percent encoding.
149
+ def escape(string)
150
+ encoding = string.encoding
151
+ string.b.gsub(/([^a-zA-Z0-9_.\-]+)/) do |m|
152
+ '%' + m.unpack('H2' * m.bytesize).join('%').upcase
153
+ end.force_encoding(encoding)
154
+ end
155
+
156
+ # According to https://tools.ietf.org/html/rfc3986#section-3.3, we escape non-pchar.
157
+ NON_PCHAR = /([^a-zA-Z0-9_\-\.~!$&'()*+,;=:@\/]+)/.freeze
158
+
159
+ # Escapes a path
160
+ def escape_path(path)
161
+ encoding = path.encoding
162
+ path.b.gsub(NON_PCHAR) do |m|
163
+ '%' + m.unpack('H2' * m.bytesize).join('%').upcase
164
+ end.force_encoding(encoding)
165
+ end
166
+
167
+ # Encodes a hash or array into a query string
168
+ def encode(value, prefix = nil)
169
+ case value
170
+ when Array
171
+ return value.map {|v|
172
+ encode(v, "#{prefix}[]")
173
+ }.join("&")
174
+ when Hash
175
+ return value.map {|k, v|
176
+ encode(v, prefix ? "#{prefix}[#{escape(k.to_s)}]" : escape(k.to_s))
177
+ }.reject(&:empty?).join('&')
178
+ when nil
179
+ return prefix
180
+ else
181
+ raise ArgumentError, "value must be a Hash" if prefix.nil?
182
+
183
+ return "#{prefix}=#{escape(value.to_s)}"
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -20,6 +20,6 @@
20
20
 
21
21
  module HTTP
22
22
  module Protocol
23
- VERSION = "0.3.2"
23
+ VERSION = "0.4.0"
24
24
  end
25
25
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: http-protocol
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-08-21 00:00:00.000000000 Z
11
+ date: 2018-09-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: http-hpack
@@ -83,6 +83,8 @@ files:
83
83
  - lib/http/protocol.rb
84
84
  - lib/http/protocol/error.rb
85
85
  - lib/http/protocol/headers.rb
86
+ - lib/http/protocol/http10/connection.rb
87
+ - lib/http/protocol/http11/connection.rb
86
88
  - lib/http/protocol/http2/client.rb
87
89
  - lib/http/protocol/http2/connection.rb
88
90
  - lib/http/protocol/http2/continuation_frame.rb
@@ -101,6 +103,7 @@ files:
101
103
  - lib/http/protocol/http2/settings_frame.rb
102
104
  - lib/http/protocol/http2/stream.rb
103
105
  - lib/http/protocol/http2/window_update_frame.rb
106
+ - lib/http/protocol/reference.rb
104
107
  - lib/http/protocol/version.rb
105
108
  homepage: https://github.com/socketry/http-protocol
106
109
  licenses: []
@@ -121,7 +124,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
121
124
  version: '0'
122
125
  requirements: []
123
126
  rubyforge_project:
124
- rubygems_version: 2.7.6
127
+ rubygems_version: 2.7.7
125
128
  signing_key:
126
129
  specification_version: 4
127
130
  summary: Provides abstractions to handle HTTP1 and HTTP2 protocols.