Skip to content

Commit a9a7502

Browse files
committed
fix(http-client): replace Tempfile with StringIO for multipart uploads
- Replace Tempfile with StringIO in prepare_multipart_payload to eliminate filesystem dependencies - Add enhanced StringIO compatibility methods for HTTParty multipart handling - Update test expectations from Tempfile to StringIO - Add defensive file closing in messages.rb - Resolves issues with read-only containers and AWS Lambda environments Fixes #528
1 parent 509ea57 commit a9a7502

File tree

4 files changed

+362
-57
lines changed

4 files changed

+362
-57
lines changed
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
# Example demonstrating file upload functionality in the Nylas Ruby SDK
5+
# Tests both small (<3MB) and large (>3MB) file handling with the new HTTParty implementation
6+
#
7+
# This example shows how to:
8+
# 1. Send messages with small attachments (<3MB) - handled as JSON with base64 encoding
9+
# 2. Send messages with large attachments (>3MB) - handled as multipart form data
10+
# 3. Create test files of appropriate sizes for demonstration
11+
# 4. Handle file upload errors and responses
12+
#
13+
# Prerequisites:
14+
# - Ruby 3.0 or later
15+
# - A Nylas API key
16+
# - A grant ID (connected email account)
17+
# - A test email address to send to
18+
#
19+
# Environment variables needed:
20+
# export NYLAS_API_KEY="your_api_key"
21+
# export NYLAS_GRANT_ID="your_grant_id"
22+
# export NYLAS_TEST_EMAIL="test@example.com" # Email address to send test messages to
23+
# export NYLAS_API_URI="https://api.us.nylas.com" # Optional
24+
#
25+
# Alternatively, create a .env file in the examples directory with:
26+
# NYLAS_API_KEY=your_api_key
27+
# NYLAS_GRANT_ID=your_grant_id
28+
# NYLAS_TEST_EMAIL=test@example.com
29+
# NYLAS_API_URI=https://api.us.nylas.com
30+
31+
$LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
32+
require "nylas"
33+
require "json"
34+
require "tempfile"
35+
36+
# Enhanced error logging helper
37+
def log_detailed_error(error, context = "")
38+
puts "\n❌ ERROR DETAILS #{context.empty? ? '' : "- #{context}"}"
39+
puts "=" * 60
40+
puts "Error Class: #{error.class}"
41+
puts "Error Message: #{error.message}"
42+
43+
if error.respond_to?(:response) && error.response
44+
puts "HTTP Response Code: #{error.response.code}" if error.response.respond_to?(:code)
45+
puts "HTTP Response Body: #{error.response.body}" if error.response.respond_to?(:body)
46+
puts "HTTP Response Headers: #{error.response.headers}" if error.response.respond_to?(:headers)
47+
end
48+
49+
if error.respond_to?(:request_id) && error.request_id
50+
puts "Request ID: #{error.request_id}"
51+
end
52+
53+
puts "Full Stack Trace:"
54+
puts error.backtrace.join("\n")
55+
puts "=" * 60
56+
end
57+
58+
# Simple .env file loader
59+
def load_env_file
60+
env_file = File.expand_path('../.env', __dir__)
61+
return unless File.exist?(env_file)
62+
63+
puts "Loading environment variables from .env file..."
64+
File.readlines(env_file).each do |line|
65+
line = line.strip
66+
next if line.empty? || line.start_with?('#')
67+
68+
key, value = line.split('=', 2)
69+
next unless key && value
70+
71+
# Remove quotes if present
72+
value = value.gsub(/\A['"]|['"]\z/, '')
73+
ENV[key] = value
74+
end
75+
rescue => e
76+
log_detailed_error(e, "loading .env file")
77+
raise
78+
end
79+
80+
# Def send message with small attachment
81+
def send_message_with_attachment(nylas, grant_id, recipient_email, test_file_path, content_type)
82+
# load the file and read it's contents
83+
file_contents = File.read(test_file_path)
84+
85+
# manually build file_attachment
86+
file_attachment = {
87+
filename: File.basename(test_file_path),
88+
content_type: content_type,
89+
content: file_contents,
90+
size: File.size(test_file_path)
91+
}
92+
93+
request_body = {
94+
subject: "Test Email with Attachment",
95+
to: [{ email: recipient_email }],
96+
body: "This is a test email with a attachment.\n\nFile size: #{File.size(test_file_path)} bytes\nSent at: #{Time.now}",
97+
attachments: [file_attachment]
98+
}
99+
100+
puts "- Sending message with large attachment..."
101+
puts "- Recipient: #{recipient_email}"
102+
puts "- Attachment size: #{File.size(test_file_path)} bytes"
103+
puts "- Expected handling: Multipart form data"
104+
puts "- Request body keys: #{request_body.keys}"
105+
106+
response, request_id = nylas.messages.send(
107+
identifier: grant_id,
108+
request_body: request_body
109+
)
110+
111+
puts "Response: #{response}"
112+
puts "Request ID: #{request_id}"
113+
puts "Grant ID: #{response[:grant_id]}"
114+
puts "Message ID: #{response[:id]}"
115+
puts "Message Subject: #{response[:subject]}"
116+
puts "Message Body: #{response[:body]}"
117+
118+
end
119+
120+
def main
121+
puts "=== Nylas File Upload Example - HTTParty Migration Test ==="
122+
123+
begin
124+
# Load .env file if it exists
125+
load_env_file
126+
127+
# Check for required environment variables
128+
api_key = ENV["NYLAS_API_KEY"]
129+
grant_id = ENV["NYLAS_GRANT_ID"]
130+
test_email = ENV["NYLAS_TEST_EMAIL"]
131+
132+
puts "- Checking environment variables..."
133+
raise "NYLAS_API_KEY environment variable is not set" unless api_key
134+
raise "NYLAS_GRANT_ID environment variable is not set" unless grant_id
135+
raise "NYLAS_TEST_EMAIL environment variable is not set" unless test_email
136+
137+
puts "Using API key: #{api_key[0..4]}..."
138+
puts "Using grant ID: #{grant_id[0..8]}..."
139+
puts "Test email recipient: #{test_email}"
140+
puts "API URI: #{ENV["NYLAS_API_URI"] || "https://api.us.nylas.com"}"
141+
142+
# Initialize the Nylas client
143+
puts "- Initializing Nylas client..."
144+
nylas = Nylas::Client.new(
145+
api_key: api_key,
146+
api_uri: ENV["NYLAS_API_URI"] || "https://api.us.nylas.com"
147+
)
148+
puts "- Nylas client initialized successfully"
149+
150+
# Demonstrate file handling logic
151+
jpg_file_path = File.expand_path("large_jpg_test_file.jpg", __dir__)
152+
unless File.exist?(jpg_file_path)
153+
raise "JPG test file not found at #{jpg_file_path}. Please create a JPG file for testing."
154+
end
155+
send_message_with_attachment(nylas, grant_id, test_email, jpg_file_path, "image/jpeg")
156+
157+
puts "\n=== File Upload Example Completed Successfully ==="
158+
rescue => e
159+
log_detailed_error(e, "main method")
160+
raise
161+
end
162+
end
163+
164+
main

lib/nylas/handler/http_client.rb

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -146,24 +146,38 @@ def httparty_execute(method:, url:, headers:, payload:, timeout:)
146146
timeout: timeout
147147
}
148148

149-
# Handle multipart uploads
150-
if payload.is_a?(Hash) && file_upload?(payload)
151-
options[:multipart] = true
152-
options[:body] = prepare_multipart_payload(payload)
153-
elsif payload
154-
options[:body] = payload
155-
end
149+
temp_files_to_cleanup = []
150+
151+
begin
152+
# Handle multipart uploads
153+
if payload.is_a?(Hash) && file_upload?(payload)
154+
options[:multipart] = true
155+
options[:body], temp_files_to_cleanup = prepare_multipart_payload(payload)
156+
elsif payload
157+
options[:body] = payload
158+
end
156159

157-
response = HTTParty.send(method, url, options)
160+
response = HTTParty.send(method, url, options)
158161

159-
# Create a compatible response object that mimics RestClient::Response
160-
result = create_response_wrapper(response)
162+
# Create a compatible response object that mimics RestClient::Response
163+
result = create_response_wrapper(response)
161164

162-
# Call the block with the response in the same format as rest-client
163-
if block_given?
164-
yield response, nil, result
165-
else
166-
response
165+
# Call the block with the response in the same format as rest-client
166+
if block_given?
167+
yield response, nil, result
168+
else
169+
response
170+
end
171+
ensure
172+
# Clean up any temporary files we created
173+
temp_files_to_cleanup.each do |tempfile|
174+
tempfile.close unless tempfile.closed?
175+
begin
176+
tempfile.unlink
177+
rescue StandardError
178+
nil
179+
end # Don't fail if file is already deleted
180+
end
167181
end
168182
end
169183

@@ -176,29 +190,69 @@ def create_response_wrapper(response)
176190
def file_upload?(payload)
177191
return false unless payload.is_a?(Hash)
178192

179-
payload.values.any? do |value|
193+
# Check for traditional file uploads (File objects or objects that respond to :read)
194+
has_file_objects = payload.values.any? do |value|
180195
value.respond_to?(:read) || (value.is_a?(File) && !value.closed?)
181196
end
197+
198+
return true if has_file_objects
199+
200+
# Check if payload was prepared by FileUtils.build_form_request for multipart uploads
201+
# This handles binary content attachments that are strings with added singleton methods
202+
has_message_field = payload.key?("message") && payload["message"].is_a?(String)
203+
has_attachment_fields = payload.keys.any? { |key| key.is_a?(String) && key.match?(/^file\d+$/) }
204+
205+
# If we have both a "message" field and "file{N}" fields, this indicates
206+
# the payload was prepared by FileUtils.build_form_request for multipart upload
207+
has_message_field && has_attachment_fields
182208
end
183209

184210
# Prepare multipart payload for HTTParty compatibility
185211
# HTTParty requires all multipart fields to have compatible encodings
186212
def prepare_multipart_payload(payload)
187-
# Only modify the "message" field if it exists and contains UTF-8 characters
188-
# that could cause encoding conflicts with binary file data
189-
if payload.key?("message") && payload["message"].is_a?(String)
190-
# Check if the message contains non-ASCII characters that could cause encoding issues
191-
message = payload["message"]
192-
if message.encoding == Encoding::UTF_8 && !message.ascii_only?
193-
# Create a copy of the payload with BINARY encoded message for HTTParty compatibility
194-
# This preserves the original UTF-8 content while preventing encoding conflicts
195-
modified_payload = payload.dup
196-
modified_payload["message"] = message.dup.force_encoding(Encoding::BINARY)
197-
return modified_payload
213+
require "stringio"
214+
215+
modified_payload = payload.dup
216+
217+
# Handle binary content attachments (file0, file1, etc.) by converting them to enhanced StringIO
218+
# HTTParty expects file uploads to be objects with full file-like interface
219+
payload.each do |key, value|
220+
next unless key.is_a?(String) && key.match?(/^file\d+$/) && value.is_a?(String)
221+
222+
# Create an enhanced StringIO object for HTTParty compatibility
223+
string_io = create_file_like_stringio(value)
224+
225+
# Preserve filename and content_type if they exist as singleton methods
226+
if value.respond_to?(:original_filename)
227+
string_io.define_singleton_method(:original_filename) { value.original_filename }
198228
end
229+
230+
if value.respond_to?(:content_type)
231+
string_io.define_singleton_method(:content_type) { value.content_type }
232+
end
233+
234+
modified_payload[key] = string_io
199235
end
200236

201-
payload
237+
# Return modified payload and empty array (no temp files to cleanup)
238+
[modified_payload, []]
239+
end
240+
241+
# Create a StringIO object that behaves more like a File for HTTParty compatibility
242+
def create_file_like_stringio(content)
243+
string_io = StringIO.new(content)
244+
245+
# Add methods that HTTParty/multipart-post might expect
246+
string_io.define_singleton_method(:path) { nil }
247+
string_io.define_singleton_method(:local_path) { nil }
248+
string_io.define_singleton_method(:respond_to_missing?) do |method_name, include_private = false|
249+
File.instance_methods.include?(method_name) || super(method_name, include_private)
250+
end
251+
252+
# Ensure binary mode for consistent behavior
253+
string_io.binmode if string_io.respond_to?(:binmode)
254+
255+
string_io
202256
end
203257

204258
def setup_http(path, timeout, headers, query, api_key)

lib/nylas/resources/messages.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def send(identifier:, request_body:)
126126
request_body: payload
127127
)
128128

129-
opened_files.each(&:close)
129+
opened_files.each { |file| file.close if file.respond_to?(:close) }
130130

131131
response
132132
end

0 commit comments

Comments
 (0)