contractful 0.1.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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/API.md +142 -0
  3. data/ARCHITECTURE.md +134 -0
  4. data/AUTHORS +106 -0
  5. data/CODE_OF_CONDUCT.md +128 -0
  6. data/LICENSE +21 -0
  7. data/README.md +1290 -0
  8. data/SECURITY.md +67 -0
  9. data/bin/contractful +128 -0
  10. data/history.md +801 -0
  11. data/lib/contractful/abstract_response.rb +301 -0
  12. data/lib/contractful/circuit_breaker.rb +221 -0
  13. data/lib/contractful/connection_pool.rb +166 -0
  14. data/lib/contractful/contract/auth.rb +257 -0
  15. data/lib/contractful/contract/config.rb +93 -0
  16. data/lib/contractful/contract/definition.rb +295 -0
  17. data/lib/contractful/contract/dsl.rb +501 -0
  18. data/lib/contractful/contract/executor.rb +492 -0
  19. data/lib/contractful/contract/openapi_import.rb +1135 -0
  20. data/lib/contractful/contract/pagination.rb +218 -0
  21. data/lib/contractful/contract/param.rb +505 -0
  22. data/lib/contractful/contract/postman_import/auth.rb +39 -0
  23. data/lib/contractful/contract/postman_import/body.rb +87 -0
  24. data/lib/contractful/contract/postman_import/operation.rb +107 -0
  25. data/lib/contractful/contract/postman_import/scope.rb +37 -0
  26. data/lib/contractful/contract/postman_import/url.rb +80 -0
  27. data/lib/contractful/contract/postman_import.rb +164 -0
  28. data/lib/contractful/contract/response_field.rb +167 -0
  29. data/lib/contractful/contract/sequence.rb +432 -0
  30. data/lib/contractful/contract/swagger2/components.rb +84 -0
  31. data/lib/contractful/contract/swagger2/operations.rb +81 -0
  32. data/lib/contractful/contract/swagger2/parameters.rb +93 -0
  33. data/lib/contractful/contract/swagger2/references.rb +55 -0
  34. data/lib/contractful/contract/swagger2.rb +55 -0
  35. data/lib/contractful/contract.rb +85 -0
  36. data/lib/contractful/exceptions.rb +328 -0
  37. data/lib/contractful/notifications/active_support.rb +40 -0
  38. data/lib/contractful/notifications.rb +138 -0
  39. data/lib/contractful/pacer.rb +161 -0
  40. data/lib/contractful/params_array.rb +85 -0
  41. data/lib/contractful/payload.rb +340 -0
  42. data/lib/contractful/platform.rb +52 -0
  43. data/lib/contractful/rate_limit.rb +133 -0
  44. data/lib/contractful/raw_response.rb +70 -0
  45. data/lib/contractful/request.rb +1483 -0
  46. data/lib/contractful/resource.rb +189 -0
  47. data/lib/contractful/response.rb +109 -0
  48. data/lib/contractful/utils.rb +202 -0
  49. data/lib/contractful/version.rb +11 -0
  50. data/lib/contractful/windows/root_certs.rb +107 -0
  51. data/lib/contractful/windows.rb +9 -0
  52. data/lib/contractful.rb +233 -0
  53. metadata +244 -0
@@ -0,0 +1,340 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require 'securerandom'
5
+ require 'stringio'
6
+
7
+ begin
8
+ # Use mime/types/columnar if available, for reduced memory usage
9
+ require 'mime/types/columnar'
10
+ rescue LoadError
11
+ require 'mime/types'
12
+ end
13
+
14
+ module Contractful
15
+ # Payload generation: turns user-provided bodies (strings, hashes,
16
+ # ParamsArray, IO-like objects) into the object contractful transmits,
17
+ # deciding urlencoded vs multipart vs streamed representation.
18
+ module Payload
19
+ extend self
20
+
21
+ def generate(params)
22
+ # nil is a valid input — it means "no body for this request" (the
23
+ # common case for GET/DELETE/HEAD). Return nil so the request
24
+ # layer skips the body entirely. Only non-nil unknown types raise
25
+ # so callers find out immediately instead of silently sending an
26
+ # empty body (e.g. `Contractful.post(url, 123)`).
27
+ return nil if params.nil?
28
+
29
+ if params.is_a?(Contractful::Payload::Base)
30
+ # pass through Payload objects unchanged
31
+ params
32
+ elsif params.is_a?(String)
33
+ Base.new(params)
34
+ elsif params.is_a?(Hash)
35
+ if params[:multipart] == true
36
+ Multipart.new(params.reject { |key, _value| key.equal?(:multipart) })
37
+ elsif has_file?(params)
38
+ Multipart.new(params)
39
+ else
40
+ UrlEncoded.new(params)
41
+ end
42
+ elsif params.is_a?(ParamsArray)
43
+ if _has_file?(params)
44
+ Multipart.new(params)
45
+ else
46
+ UrlEncoded.new(params)
47
+ end
48
+ elsif params.respond_to?(:read)
49
+ Streamed.new(params)
50
+ else
51
+ # Previously this branch was silent: any value that did not match
52
+ # one of the cases above returned nil from generate, and the
53
+ # request went out with NO body — the user's payload was lost
54
+ # without any signal. Raise so callers find out immediately
55
+ # (e.g. `Contractful.post(url, 123)` or `Contractful.post(url, [1,2,3])`).
56
+ raise ArgumentError,
57
+ "unsupported payload type #{params.class} " \
58
+ '(expected String, Hash, ParamsArray, or IO-like ' \
59
+ 'responding to #read)'
60
+ end
61
+ end
62
+
63
+ def has_file?(params)
64
+ raise ArgumentError.new("Must pass Hash, not #{params.inspect}") unless params.is_a?(Hash)
65
+
66
+ _has_file?(params)
67
+ end
68
+
69
+ def _has_file?(obj)
70
+ case obj
71
+ when Hash, ParamsArray
72
+ obj.any? { |_, v| _has_file?(v) }
73
+ when Array
74
+ obj.any? { |v| _has_file?(v) }
75
+ else
76
+ obj.respond_to?(:path) && obj.respond_to?(:read)
77
+ end
78
+ end
79
+
80
+ # Base payload wrapping a readable stream; also the concrete payload
81
+ # for plain String bodies.
82
+ class Base
83
+ def initialize(params)
84
+ build_stream(params)
85
+ end
86
+
87
+ def build_stream(params)
88
+ @stream = StringIO.new(params)
89
+ @stream.seek(0)
90
+ end
91
+
92
+ def read(*args)
93
+ @stream.read(*args)
94
+ end
95
+
96
+ def to_s
97
+ result = read
98
+ rewind_stream
99
+ result
100
+ end
101
+
102
+ def headers
103
+ { 'Content-Length' => size.to_s }
104
+ end
105
+
106
+ def size
107
+ @stream.size
108
+ end
109
+
110
+ alias length size
111
+
112
+ def close
113
+ @stream.close unless @stream.closed?
114
+ end
115
+
116
+ def closed?
117
+ @stream.closed?
118
+ end
119
+
120
+ # Rewind the payload stream to the beginning so that the payload can be
121
+ # retransmitted (for example when retrying a request after a network
122
+ # failure). Returns true if the payload was rewound, or false if the
123
+ # underlying stream does not support rewinding (e.g. a socket).
124
+ #
125
+ # @return [Boolean]
126
+ #
127
+ def rewind
128
+ if @stream.respond_to?(:rewind)
129
+ @stream.rewind
130
+ true
131
+ else
132
+ false
133
+ end
134
+ rescue IOError, SystemCallError
135
+ false
136
+ end
137
+
138
+ # Best-effort, silent variant of #rewind used when a non-rewindable
139
+ # stream is not an error (for example reading the payload for logging).
140
+ # Streams that cannot be rewound (sockets, pipes, and other non-seekable
141
+ # objects) are left positioned wherever reading left them.
142
+ def rewind_stream
143
+ @stream.seek(0) if @stream.respond_to?(:seek)
144
+ rescue IOError, SystemCallError
145
+ nil
146
+ end
147
+
148
+ def to_s_inspect
149
+ to_s.inspect
150
+ end
151
+
152
+ def short_inspect
153
+ return "#{size} byte(s) length" if size && size > 500
154
+ return 'streaming body'.inspect unless seekable?
155
+
156
+ to_s_inspect
157
+ end
158
+
159
+ private
160
+
161
+ # A stream that responds to #seek may still reject it (pipes and
162
+ # sockets raise Errno::ESPIPE). Probing the current position avoids
163
+ # consuming the stream merely to render it in a log line.
164
+ def seekable?
165
+ return false unless @stream.respond_to?(:seek)
166
+
167
+ @stream.seek(0, IO::SEEK_CUR)
168
+ true
169
+ rescue IOError, SystemCallError
170
+ false
171
+ end
172
+ end
173
+
174
+ # Payload for IO-like (streaming) bodies; the stream is consumed
175
+ # directly and may be non-seekable.
176
+ class Streamed < Base
177
+ def build_stream(params = nil)
178
+ @stream = params
179
+ end
180
+
181
+ def size
182
+ if @stream.respond_to?(:size)
183
+ @stream.size
184
+ elsif @stream.is_a?(IO)
185
+ @stream.stat.size
186
+ end
187
+ end
188
+
189
+ # TODO: (breaks compatibility): ought to use mime_for() to autodetect the
190
+ # Content-Type for stream objects that have a filename.
191
+
192
+ alias length size
193
+ end
194
+
195
+ # Payload for urlencoded form bodies (Hash/ParamsArray without files).
196
+ class UrlEncoded < Base
197
+ def build_stream(params = nil)
198
+ @stream = StringIO.new(Utils.encode_query_string(params))
199
+ @stream.seek(0)
200
+ end
201
+
202
+ def headers
203
+ super.merge({ 'Content-Type' => 'application/x-www-form-urlencoded' })
204
+ end
205
+ end
206
+
207
+ # Payload for multipart/form-data bodies (Hash/ParamsArray with file
208
+ # fields, or explicit multipart: true).
209
+ class Multipart < Base
210
+ EOL = "\r\n"
211
+
212
+ def build_stream(params)
213
+ b = '--' + boundary
214
+
215
+ @stream = Tempfile.new('contractful.multipart.')
216
+ @stream.binmode
217
+ @stream.write(b + EOL)
218
+
219
+ x = case params
220
+ when Hash, ParamsArray
221
+ Utils.flatten_params(params)
222
+ else
223
+ params
224
+ end
225
+
226
+ last_index = x.length - 1
227
+ x.each_with_index do |a, index|
228
+ key, value = * a
229
+ if value.respond_to?(:read) && value.respond_to?(:path)
230
+ create_file_field(@stream, key, value)
231
+ else
232
+ create_regular_field(@stream, key, value)
233
+ end
234
+ @stream.write(EOL + b)
235
+ @stream.write(EOL) unless last_index == index
236
+ end
237
+ @stream.write('--')
238
+ @stream.write(EOL)
239
+ @stream.seek(0)
240
+ end
241
+
242
+ def create_regular_field(stream, key, value)
243
+ # Multipart field names go into a quoted-string in the
244
+ # Content-Disposition header. A user-controlled key that contains
245
+ # `"` or CRLF can break out of the quoted-string and inject a
246
+ # synthetic part header (or another Content-Disposition with an
247
+ # attacker-chosen filename). Escape `"` as `\"` and strip CR/LF
248
+ # so the field name cannot escape its quoted-string slot.
249
+ safe_key = escape_form_field_name(key)
250
+ stream.write("Content-Disposition: form-data; name=\"#{safe_key}\"")
251
+ stream.write(EOL)
252
+ stream.write(EOL)
253
+ stream.write(value)
254
+ end
255
+
256
+ def create_file_field(stream, key, value)
257
+ # Same header-injection guard as create_regular_field, plus a
258
+ # filename escape — File.basename(value.path) is user-controlled
259
+ # (the path on disk of an uploaded file), and a filename containing
260
+ # `"`, `\r` or `\n` can inject a synthetic filename/content-type
261
+ # header into the multipart stream.
262
+ safe_key = escape_form_field_name(key)
263
+ stream.write('Content-Disposition: form-data;')
264
+ stream.write(" name=\"#{safe_key}\";") unless key.nil? || key == ''
265
+ filename = value.respond_to?(:original_filename) ? value.original_filename : File.basename(value.path)
266
+ stream.write(" filename=\"#{escape_form_field_name(filename)}\"#{EOL}")
267
+ content_type = value.respond_to?(:content_type) ? value.content_type : mime_for(value.path)
268
+ stream.write("Content-Type: #{content_type}#{EOL}")
269
+ stream.write(EOL)
270
+ while (data = value.read(8124))
271
+ stream.write(data)
272
+ end
273
+ ensure
274
+ value.close if value.respond_to?(:close)
275
+ end
276
+
277
+ # Escape a multipart form field name (the value of `name=` or
278
+ # `filename=` in Content-Disposition) so the user input cannot
279
+ # break out of the quoted-string slot in the header.
280
+ #
281
+ # - Replace `"` with `\"` (RFC 2047 quoted-string escape).
282
+ # - Strip CR and LF entirely. RFC 7578 forbids them inside the
283
+ # token, and any CRLF ends the current header line — which is
284
+ # the injection vector.
285
+ # - Return nil for nil so the caller's `unless key.nil? || key == ''`
286
+ # branch above keeps working unchanged.
287
+ def escape_form_field_name(name)
288
+ return nil if name.nil?
289
+
290
+ name.to_s.tr("\r\n", '').gsub('"', '\"')
291
+ end
292
+
293
+ def mime_for(path)
294
+ # value.path can legitimately return nil (e.g. a Tempfile that
295
+ # has been unlinked, or a StringIO subclass). MIME::Types.type_for
296
+ # calls #split on its argument, so passing nil raises
297
+ # NoMethodError and crashes the whole multipart build.
298
+ return 'text/plain' if path.nil? || path.to_s.empty?
299
+
300
+ mime = MIME::Types.type_for(path)
301
+ mime.empty? ? 'text/plain' : mime[0].content_type
302
+ end
303
+
304
+ def boundary
305
+ return @boundary if defined?(@boundary) && @boundary
306
+
307
+ # Use the same algorithm used by WebKit: generate 16 random
308
+ # alphanumeric characters, replacing `+` `/` with `A` `B` (included in
309
+ # the list twice) to round out the set of 64.
310
+ s = SecureRandom.base64(12)
311
+ # Do not mutate the generated string in place: the return value of
312
+ # SecureRandom.base64 may be frozen, and boundary can then never be
313
+ # built at all.
314
+ s = s.tr('+/', 'AB')
315
+
316
+ @boundary = '----RubyFormBoundary' + s
317
+ end
318
+
319
+ # for Multipart do not escape the keys
320
+ #
321
+ # Ostensibly multipart keys MAY be percent encoded per RFC 7578, but in
322
+ # practice no major browser that I'm aware of uses percent encoding.
323
+ #
324
+ # Further discussion of multipart encoding:
325
+ # https://github.com/rest-client/rest-client/pull/403#issuecomment-156976930
326
+ #
327
+ def handle_key key
328
+ key
329
+ end
330
+
331
+ def headers
332
+ super.merge({ 'Content-Type' => "multipart/form-data; boundary=#{boundary}" })
333
+ end
334
+
335
+ def close
336
+ @stream.close!
337
+ end
338
+ end
339
+ end
340
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+
5
+ module Contractful
6
+ # Platform introspection: user agent strings and windows? detection.
7
+ module Platform
8
+ # Return true if we are running on a darwin-based Ruby platform. This will
9
+ # be false for jruby even on OS X.
10
+ #
11
+ # @return [Boolean]
12
+ def self.mac_mri?
13
+ RUBY_PLATFORM.include?('darwin')
14
+ end
15
+
16
+ # Return true if we are running on Windows.
17
+ #
18
+ # @return [Boolean]
19
+ #
20
+ def self.windows?
21
+ # Ruby only sets File::ALT_SEPARATOR on Windows, and the Ruby standard
22
+ # library uses that to test what platform it's on.
23
+ !!File::ALT_SEPARATOR
24
+ end
25
+
26
+ # Return true if we are running on jruby.
27
+ #
28
+ # @return [Boolean]
29
+ #
30
+ def self.jruby?
31
+ # defined on mri >= 1.9
32
+ RUBY_ENGINE == 'jruby'
33
+ end
34
+
35
+ def self.architecture
36
+ "#{RbConfig::CONFIG['host_os']} #{RbConfig::CONFIG['host_cpu']}"
37
+ end
38
+
39
+ def self.ruby_agent_version
40
+ case RUBY_ENGINE
41
+ when 'jruby'
42
+ "jruby/#{JRUBY_VERSION} (#{RUBY_VERSION}p#{RUBY_PATCHLEVEL})"
43
+ else
44
+ "#{RUBY_ENGINE}/#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}"
45
+ end
46
+ end
47
+
48
+ def self.default_user_agent
49
+ "contractful/#{VERSION} (#{architecture}) #{ruby_agent_version}"
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Contractful
6
+ # Parsed rate-limit information from a response's headers.
7
+ #
8
+ # Understands both the IETF ratelimit headers (RateLimit-Limit /
9
+ # RateLimit-Remaining / RateLimit-Reset / RateLimit-Policy) and the legacy
10
+ # X-RateLimit-* convention, preferring the IETF spelling when both are
11
+ # present.
12
+ #
13
+ # info = response.rate_limit
14
+ # info.limit # => 5000
15
+ # info.remaining # => 4998
16
+ # info.reset # => Time (or nil when the server does not say)
17
+ # info.policy # => "10;w=1" (or nil)
18
+ # info.exhausted? # => true when remaining is reported as 0
19
+ # info.reset_in # => seconds until the quota resets (or nil)
20
+ #
21
+ # APIs may document their rate limits in prose without ever sending the
22
+ # headers (DhanHQ, for example) — #rate_limit then returns nil, which is
23
+ # data absence, not an error.
24
+ class RateLimit
25
+ # Leading-integer extraction handles both plain integers ("5000") and
26
+ # the IETF "limit, limit;w=window" description form.
27
+ LEADING_INTEGER = /\A\s*(\d+)/
28
+
29
+ attr_reader :limit, :remaining, :reset, :policy
30
+
31
+ # The raw parsed header values, for diagnostics.
32
+ attr_reader :raw
33
+
34
+ def initialize(limit: nil, remaining: nil, reset: nil, policy: nil, raw: {})
35
+ @limit = limit
36
+ @remaining = remaining
37
+ @reset = reset
38
+ @policy = policy
39
+ @raw = raw.freeze
40
+ end
41
+
42
+ # Build a RateLimit from a beautified headers hash (lowercase underscore
43
+ # symbols, as produced by AbstractResponse#headers). Returns nil when no
44
+ # rate-limit headers are present at all.
45
+ def self.from_headers(headers)
46
+ return nil unless headers.is_a?(Hash)
47
+
48
+ parsed = {}
49
+ parsed[:limit] = parse_limit(pick(headers, :ratelimit_limit, :x_ratelimit_limit))
50
+ parsed[:remaining] = parse_integer(pick(headers, :ratelimit_remaining, :x_ratelimit_remaining))
51
+ parsed[:reset] = parse_reset(pick(headers, :ratelimit_reset),
52
+ pick(headers, :x_ratelimit_reset))
53
+ parsed[:policy] = pick(headers, :ratelimit_policy)
54
+
55
+ raw = {}
56
+ headers.each do |key, value|
57
+ raw[key] = value if key.to_s.start_with?('ratelimit_', 'x_ratelimit_')
58
+ end
59
+ parsed[:raw] = raw
60
+
61
+ return nil unless parsed[:limit] || parsed[:remaining] || parsed[:reset] || parsed[:policy]
62
+
63
+ new(**parsed)
64
+ end
65
+
66
+ # True when the server reports the quota as used up.
67
+ def exhausted?
68
+ remaining == 0
69
+ end
70
+
71
+ # Seconds until the quota resets (never negative), or nil when unknown.
72
+ def reset_in
73
+ return nil unless reset
74
+
75
+ secs = reset - Time.now
76
+ secs.positive? ? secs : 0.0
77
+ end
78
+
79
+ def to_s
80
+ parts = []
81
+ parts << "#{remaining}/#{limit}" if limit || remaining
82
+ parts << "resets in #{reset_in&.round(3)}s" if reset
83
+ parts << "policy #{policy}" if policy
84
+ parts.empty? ? 'RateLimit (no data)' : "RateLimit #{parts.join(', ')}"
85
+ end
86
+
87
+ def inspect
88
+ "#<Contractful::RateLimit limit=#{limit.inspect} remaining=#{remaining.inspect} " \
89
+ "reset=#{reset.inspect} policy=#{policy.inspect}>"
90
+ end
91
+
92
+ # Pick the first present value among candidate header keys (keys are
93
+ # beautified symbols; values may be single strings or arrays).
94
+ def self.pick(headers, *names)
95
+ names.each do |name|
96
+ value = headers[name]
97
+ value = value.first if value.is_a?(Array)
98
+ value = value.to_s.strip
99
+ return value unless value.empty?
100
+ end
101
+ nil
102
+ end
103
+
104
+ def self.parse_limit(value)
105
+ return nil unless value
106
+
107
+ match = value.match(LEADING_INTEGER)
108
+ match && match[1].to_i
109
+ end
110
+
111
+ def self.parse_integer(value)
112
+ parse_limit(value)
113
+ end
114
+
115
+ # IETF reset is a duration in seconds or an HTTP-date; the legacy
116
+ # X-RateLimit-Reset is an epoch timestamp.
117
+ def self.parse_reset(ietf_value, legacy_value)
118
+ if ietf_value
119
+ if ietf_value.match?(/\A\d+\z/)
120
+ Time.now + ietf_value.to_f
121
+ else
122
+ begin
123
+ Time.httpdate(ietf_value)
124
+ rescue StandardError
125
+ nil
126
+ end
127
+ end
128
+ elsif legacy_value&.match?(/\A\d+\z/)
129
+ Time.at(legacy_value.to_i)
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contractful
4
+ # The response from Contractful on a raw request looks like a string, but is
5
+ # actually one of these. 99% of the time you're making a rest call all you
6
+ # care about is the body, but on the occasion you want to fetch the
7
+ # headers you can:
8
+ #
9
+ # Contractful.get('http://example.com').headers[:content_type]
10
+ #
11
+ # In addition, if you do not use the response as a string, you can access
12
+ # a Tempfile object at res.file, which contains the path to the raw
13
+ # downloaded request body.
14
+ class RawResponse
15
+ include AbstractResponse
16
+
17
+ attr_reader :file, :request, :start_time, :end_time
18
+
19
+ def inspect
20
+ "<Contractful::RawResponse @code=#{code.inspect}, @file=#{file.inspect}, @request=#{request.inspect}>"
21
+ end
22
+
23
+ # @param [Tempfile] tempfile The temporary file containing the body
24
+ # @param [Net::HTTPResponse] net_http_res
25
+ # @param [Contractful::Request] request
26
+ # @param [Time] start_time
27
+ def initialize(tempfile, net_http_res, request, start_time = nil)
28
+ @file = tempfile
29
+
30
+ # reopen the tempfile so we can read it. Tempfile#open does NOT
31
+ # preserve the binmode flag that fetch_body_to_tempfile set
32
+ # (https://github.com/ruby/tempfile/blob/v0.3.0/lib/tempfile.rb).
33
+ # On Windows the reopened file is text mode, which corrupts binary
34
+ # bodies (image, gzip) via CRLF translation. Re-apply binmode
35
+ # unconditionally; on Linux it is a no-op.
36
+ @file.open
37
+ @file.binmode
38
+
39
+ response_set_vars(net_http_res, request, start_time)
40
+ end
41
+
42
+ def to_s
43
+ body
44
+ end
45
+
46
+ def body
47
+ @file.rewind
48
+ @file.read
49
+ end
50
+
51
+ def size
52
+ file.size
53
+ end
54
+
55
+ # Close and unlink the underlying tempfile. Long-lived processes
56
+ # that issue many raw_response: true requests leak a file
57
+ # descriptor per RawResponse until GC finalizes the Tempfile — call
58
+ # this method when the body is no longer needed to release the FD
59
+ # and the on-disk file immediately. After this call, #body and
60
+ # #size raise (the file is gone); callers that want to keep the
61
+ # body around should read it into a String before closing.
62
+ def close
63
+ return if @file.nil?
64
+
65
+ @file.close(true) if @file.respond_to?(:close)
66
+ @file = nil
67
+ end
68
+ alias close! close
69
+ end
70
+ end