22
33require "httparty"
44require "net/http"
5+ require "net/http/post/multipart"
56
67require_relative "../errors"
78require_relative "../version"
@@ -135,40 +136,104 @@ def parse_response(response)
135136
136137 private
137138
138- # Sends a request to the Nylas REST API using HTTParty.
139+ # Sends a request to the Nylas REST API using HTTParty or Net::HTTP for multipart.
140+ # Multipart requests use Net::HTTP::Post::Multipart (multipart-post gem) because
141+ # HTTParty's multipart handling produces malformed requests that the Nylas API rejects.
139142 #
140143 # @param method [Symbol] HTTP method for the API call. Either :get, :post, :delete, or :patch.
141144 # @param url [String] URL for the API call.
142145 # @param headers [Hash] HTTP headers to include in the payload.
143146 # @param payload [String, Hash] Body to send with the request.
144147 # @param timeout [Hash] Timeout value to send with the request.
145148 def httparty_execute ( method :, url :, headers :, payload :, timeout :)
146- options = {
147- headers : headers ,
148- timeout : timeout
149- }
150-
151- # Handle multipart uploads
152- if payload . is_a? ( Hash ) && file_upload? ( payload )
153- options [ :multipart ] = true
154- options [ :body ] = prepare_multipart_payload ( payload )
155- elsif payload
156- options [ :body ] = payload
149+ if method == :post && payload . is_a? ( Hash ) && file_upload? ( payload )
150+ response = execute_multipart_request ( url : url , headers : headers , payload : payload , timeout : timeout )
151+ else
152+ options = { headers : headers , timeout : timeout }
153+ options [ :body ] = payload if payload
154+ response = HTTParty . send ( method , url , options )
157155 end
158156
159- response = HTTParty . send ( method , url , options )
160-
161- # Create a compatible response object that mimics RestClient::Response
162157 result = create_response_wrapper ( response )
163158
164- # Call the block with the response in the same format as rest-client
165159 if block_given?
166160 yield response , nil , result
167161 else
168162 response
169163 end
170164 end
171165
166+ # Executes multipart POST using Net::HTTP::Post::Multipart (fixes issue #538).
167+ # HTTParty's multipart produces malformed requests; multipart-post/UploadIO works correctly.
168+ def execute_multipart_request ( url :, headers :, payload :, timeout :)
169+ uri = URI . parse ( url )
170+ params = build_multipart_params ( payload )
171+
172+ req = Net ::HTTP ::Post ::Multipart . new ( uri . path , params )
173+ headers . each { |key , value | req [ key ] = value }
174+
175+ http = Net ::HTTP . new ( uri . host , uri . port )
176+ http . use_ssl = ( uri . scheme == "https" )
177+ http . read_timeout = timeout
178+ http . open_timeout = timeout
179+
180+ response = http . request ( req )
181+
182+ create_httparty_like_response ( response )
183+ end
184+
185+ # Build params hash for Net::HTTP::Post::Multipart with UploadIO for file fields.
186+ def build_multipart_params ( payload )
187+ params = { }
188+ payload . each do |key , value |
189+ params [ key ] = if key . is_a? ( String ) && key != "message" && file_like_value? ( value )
190+ value_to_upload_io ( value )
191+ else
192+ value . to_s
193+ end
194+ end
195+ params
196+ end
197+
198+ def file_like_value? ( value )
199+ return true if value . respond_to? ( :read ) && ( value . is_a? ( File ) ? !value . closed? : true )
200+ if value . is_a? ( String ) && ( value . respond_to? ( :original_filename ) || value . respond_to? ( :content_type ) )
201+ return true
202+ end
203+
204+ false
205+ end
206+
207+ # Convert File, String, or StringIO to UploadIO for multipart-post.
208+ def value_to_upload_io ( value )
209+ content_type = value . respond_to? ( :content_type ) ? value . content_type : "application/octet-stream"
210+ filename = value . respond_to? ( :original_filename ) ? value . original_filename : "file.bin"
211+
212+ io = if value . respond_to? ( :read ) && value . respond_to? ( :rewind )
213+ value . rewind if value . respond_to? ( :rewind )
214+ value
215+ else
216+ require "stringio"
217+ content = value . to_s
218+ content = content . dup . force_encoding ( Encoding ::ASCII_8BIT ) if content . is_a? ( String )
219+ StringIO . new ( content )
220+ end
221+
222+ UploadIO . new ( io , content_type , filename )
223+ end
224+
225+ # Create response object compatible with HTTParty::Response interface.
226+ def create_httparty_like_response ( net_http_response )
227+ headers = net_http_response . to_hash
228+ headers = headers . transform_values { |v | v . is_a? ( Array ) && v . one? ? v . first : v }
229+
230+ OpenStruct . new (
231+ body : net_http_response . body ,
232+ code : net_http_response . code . to_i ,
233+ headers : headers
234+ )
235+ end
236+
172237 # Create a response wrapper that mimics RestClient::Response.code behavior
173238 def create_response_wrapper ( response )
174239 OpenStruct . new ( code : response . code )
@@ -188,10 +253,20 @@ def file_upload?(payload)
188253 # Check if payload was prepared by FileUtils.build_form_request for multipart uploads
189254 # This handles binary content attachments that are strings with added singleton methods
190255 has_message_field = payload . key? ( "message" ) && payload [ "message" ] . is_a? ( String )
191- has_attachment_fields = payload . keys . any? { |key | key . is_a? ( String ) && key . match? ( /^file\d +$/ ) }
192256
193- # If we have both a "message" field and "file{N}" fields, this indicates
194- # the payload was prepared by FileUtils.build_form_request for multipart upload
257+ # Check for attachment fields - these can have custom content_id values (not just "file{N}")
258+ # FileUtils.build_form_request creates entries with string values that have singleton methods
259+ # like original_filename and content_type defined on them
260+ has_attachment_fields = payload . any? do |key , value |
261+ next false unless key . is_a? ( String ) && key != "message"
262+
263+ # Check if the value is a string with attachment-like singleton methods
264+ # (original_filename or content_type), which indicates it's a file content
265+ value . is_a? ( String ) && ( value . respond_to? ( :original_filename ) || value . respond_to? ( :content_type ) )
266+ end
267+
268+ # If we have both a "message" field and attachment fields with file metadata,
269+ # this indicates the payload was prepared by FileUtils.build_form_request
195270 has_message_field && has_attachment_fields
196271 end
197272
0 commit comments