File: chunked.rb

package info (click to toggle)
ruby-protocol-http1 0.35.2-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 376 kB
  • sloc: ruby: 2,367; makefile: 4
file content (149 lines) | stat: -rw-r--r-- 4,212 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2019-2025, by Samuel Williams.
# Copyright, 2023, by Thomas Morgan.

require "protocol/http/body/readable"

module Protocol
	module HTTP1
		module Body
			# Represents a chunked body, which is a series of chunks, each with a length prefix.
			#
			# See https://tools.ietf.org/html/rfc7230#section-4.1 for more details on the chunked transfer encoding.
			class Chunked < HTTP::Body::Readable
				CRLF = "\r\n"
				
				# Initialize the chunked body.
				#
				# @parameter connection [Protocol::HTTP1::Connection] the connection to read the body from.
				# @parameter headers [Protocol::HTTP::Headers] the headers to read the trailer into, if any.
				def initialize(connection, headers)
					@connection = connection
					@finished = false
					
					@headers = headers
					
					@length = 0
					@count = 0
				end
				
				# @attribute [Integer] the number of chunks read so far.
				attr :count
				
				# @attribute [Integer] the length of the body if known.
				def length
					# We only know the length once we've read the final chunk:
					if @finished
						@length
					end
				end
				
				# @returns [Boolean] true if the body is empty, in other words {read} will return `nil`.
				def empty?
					@connection.nil?
				end
				
				# Close the connection and mark the body as finished.
				#
				# @parameter error [Exception | Nil] the error that caused the body to be closed, if any.
				def close(error = nil)
					if connection = @connection
						@connection = nil
						
						unless @finished
							connection.close_read
						end
					end
					
					super
				end
				
				VALID_CHUNK_LENGTH = /\A[0-9a-fA-F]+\z/
				
				# Read a chunk of data.
				#
				# Follows the procedure outlined in https://tools.ietf.org/html/rfc7230#section-4.1.3
				#
				# @returns [String | Nil] the next chunk of data, or `nil` if the body is finished.
				# @raises [EOFError] if the connection is closed before the expected length is read.
				def read
					if !@finished
						if @connection
							length, _extensions = @connection.read_line.split(";", 2)
							
							unless length =~ VALID_CHUNK_LENGTH
								raise BadRequest, "Invalid chunk length: #{length.inspect}"
							end
							
							# It is possible this line contains chunk extension, so we use `to_i` to only consider the initial integral part:
							length = Integer(length, 16)
							
							if length == 0
								read_trailer
								
								# The final chunk has been read and the connection is now closed:
								@connection.receive_end_stream!
								@connection = nil
								@finished = true
								
								return nil
							end
							
							# Read trailing CRLF:
							chunk = @connection.read(length + 2)
							
							if chunk.bytesize == length + 2
								# ...and chomp it off:
								chunk.chomp!(CRLF)
								
								@length += length
								@count += 1
								
								return chunk
							else
								# The connection has been closed before we have read the requested length:
								@connection.close_read
								@connection = nil
							end
						end
						
						# If the connection has been closed before we have read the final chunk, raise an error:
						raise EOFError, "connection closed before expected length was read!"
					end
				end
				
				# @returns [String] a human-readable representation of the body.
				def inspect
					"\#<#{self.class} #{@length} bytes read in #{@count} chunks, #{@finished ? 'finished' : 'reading'}>"
				end
				
				# @returns [Hash] JSON representation for tracing and debugging.
				def as_json(...)
					super.merge(
						count: @count,
						finished: @finished,
						state: @connection ? "open" : "closed"
					)
				end
				
				private
				
				# Read the trailer from the connection, and add any headers to the trailer.
				def read_trailer
					while line = @connection.read_line?
						# Empty line indicates end of trailer:
						break if line.empty?
						
						if match = line.match(HEADER)
							@headers.add(match[1], match[2])
						else
							raise BadHeader, "Could not parse header: #{line.inspect}"
						end
					end
				end
			end
		end
	end
end