http-protocol 0.3.2 → 0.4.0

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.
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.