File: streaming_multipart_body.rb

package info (click to toggle)
ruby-httparty 0.24.2-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 964 kB
  • sloc: ruby: 7,521; xml: 425; sh: 35; makefile: 14
file content (190 lines) | stat: -rw-r--r-- 5,021 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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# frozen_string_literal: true

module HTTParty
  class Request
    class StreamingMultipartBody
      NEWLINE = "\r\n"
      CHUNK_SIZE = 64 * 1024 # 64 KB chunks

      def initialize(parts, boundary)
        @parts = parts
        @boundary = boundary
        @part_index = 0
        @state = :header
        @current_file = nil
        @header_buffer = nil
        @header_offset = 0
        @footer_sent = false
      end

      def size
        @size ||= calculate_size
      end

      def read(length = nil, outbuf = nil)
        outbuf = outbuf ? outbuf.replace(''.b) : ''.b

        return read_all(outbuf) if length.nil?

        while outbuf.bytesize < length
          chunk = read_chunk(length - outbuf.bytesize)
          break if chunk.nil?
          outbuf << chunk
        end

        outbuf.empty? ? nil : outbuf
      end

      def rewind
        @part_index = 0
        @state = :header
        @current_file = nil
        @header_buffer = nil
        @header_offset = 0
        @footer_sent = false
        @parts.each do |_key, value, _is_file|
          value.rewind if value.respond_to?(:rewind)
        end
      end

      private

      def read_all(outbuf)
        while (chunk = read_chunk(CHUNK_SIZE))
          outbuf << chunk
        end
        outbuf.empty? ? nil : outbuf
      end

      def read_chunk(max_length)
        loop do
          return nil if @part_index >= @parts.size && @footer_sent

          if @part_index >= @parts.size
            @footer_sent = true
            return "--#{@boundary}--#{NEWLINE}".b
          end

          key, value, is_file = @parts[@part_index]

          case @state
          when :header
            chunk = read_header_chunk(key, value, is_file, max_length)
            return chunk if chunk

          when :body
            chunk = read_body_chunk(value, is_file, max_length)
            return chunk if chunk

          when :newline
            @state = :header
            @part_index += 1
            return NEWLINE.b
          end
        end
      end

      def read_header_chunk(key, value, is_file, max_length)
        if @header_buffer.nil?
          @header_buffer = build_part_header(key, value, is_file)
          @header_offset = 0
        end

        remaining = @header_buffer.bytesize - @header_offset
        if remaining > 0
          chunk_size = [remaining, max_length].min
          chunk = @header_buffer.byteslice(@header_offset, chunk_size)
          @header_offset += chunk_size
          return chunk
        end

        @header_buffer = nil
        @header_offset = 0
        @state = :body
        nil
      end

      def read_body_chunk(value, is_file, max_length)
        if is_file
          chunk = read_file_chunk(value, max_length)
          if chunk
            return chunk
          else
            @current_file = nil
            @state = :newline
            return nil
          end
        else
          @state = :newline
          return value.to_s.b
        end
      end

      def read_file_chunk(file, max_length)
        chunk_size = [max_length, CHUNK_SIZE].min
        chunk = file.read(chunk_size)
        return nil if chunk.nil?
        chunk.force_encoding(Encoding::BINARY) if chunk.respond_to?(:force_encoding)
        chunk
      end

      def build_part_header(key, value, is_file)
        header = "--#{@boundary}#{NEWLINE}".b
        header << %(Content-Disposition: form-data; name="#{key}").b
        if is_file
          header << %(; filename="#{file_name(value).gsub(/["\r\n]/, replacement_table)}").b
          header << NEWLINE.b
          header << "Content-Type: #{content_type(value)}#{NEWLINE}".b
        else
          header << NEWLINE.b
        end
        header << NEWLINE.b
        header
      end

      def calculate_size
        total = 0
        @parts.each do |key, value, is_file|
          total += build_part_header(key, value, is_file).bytesize
          total += content_size(value, is_file)
          total += NEWLINE.bytesize
        end
        total += "--#{@boundary}--#{NEWLINE}".bytesize
        total
      end

      def content_size(value, is_file)
        if is_file
          if value.respond_to?(:size)
            value.size
          elsif value.respond_to?(:stat)
            value.stat.size
          else
            value.read.bytesize.tap { value.rewind }
          end
        else
          value.to_s.b.bytesize
        end
      end

      def content_type(object)
        return object.content_type if object.respond_to?(:content_type)
        require 'mini_mime'
        mime = MiniMime.lookup_by_filename(object.path)
        mime ? mime.content_type : 'application/octet-stream'
      end

      def file_name(object)
        object.respond_to?(:original_filename) ? object.original_filename : File.basename(object.path)
      end

      def replacement_table
        @replacement_table ||= {
          '"'  => '%22',
          "\r" => '%0D',
          "\n" => '%0A'
        }.freeze
      end
    end
  end
end