curl: the libcurl handle is initiated once per request object, closed by GC or hand
[nit.git] / lib / curl / curl.nit
1 # This file is part of NIT ( http://www.nitlanguage.org ).
2 #
3 # Copyright 2013 Matthieu Lucas <lucasmatthieu@gmail.com>
4 #
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
8 #
9 # http://www.apache.org/licenses/LICENSE-2.0
10 #
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and
15 # limitations under the License.
16
17 # Data transfer powered by the native curl library
18 #
19 # Download or upload over HTTP with `CurlHTTPRequest` and send emails with `CurlMail`.
20 module curl
21
22 import native_curl
23
24 # Curl library handle
25 private class Curl
26 super FinalizableOnce
27
28 var native = new NativeCurl.easy_init
29
30 # Is this instance correctly initialized?
31 fun is_ok: Bool do return self.native.is_init
32
33 redef fun finalize_once do if is_ok then native.easy_clean
34 end
35
36 # CURL Request
37 class CurlRequest
38
39 private var curl = new Curl
40
41 # Shall this request be verbose?
42 var verbose: Bool = false is writable
43
44 # Intern perform method, lowest level of request launching
45 private fun perform: nullable CurlResponseFailed
46 do
47 if not self.curl.is_ok then return answer_failure(0, "Curl instance is not correctly initialized")
48
49 var err
50
51 err = self.curl.native.easy_setopt(new CURLOption.verbose, self.verbose)
52 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
53
54 err = self.curl.native.easy_perform
55 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
56
57 return null
58 end
59
60 # Intern method with return a failed answer with given code and message
61 private fun answer_failure(error_code: Int, error_msg: String): CurlResponseFailed
62 do
63 return new CurlResponseFailed(error_code, error_msg)
64 end
65
66 # Close low-level resources associated to this request
67 #
68 # Once closed, this request can't be used again.
69 #
70 # If this service isn't called explicitly, low-level resources
71 # may be freed automatically by the GC.
72 fun close do curl.finalize
73 end
74
75 # HTTP request builder
76 #
77 # The request itself is sent by either `execute` or `download_to_file`.
78 # The attributes of this class must be set before calling either of these two methods.
79 #
80 # ## Minimal usage example
81 #
82 # ~~~
83 # var request = new CurlHTTPRequest("http://example.org/")
84 # var response = request.execute
85 # if response isa CurlResponseSuccess then
86 # print "Response status code: {response.status_code}"
87 # print response.body_str
88 # else if response isa CurlResponseFailed then
89 # print_error response.error_msg
90 # end
91 # ~~~
92 class CurlHTTPRequest
93 super CurlRequest
94 super NativeCurlCallbacks
95
96 # Address of the remote resource to request
97 var url: String
98
99 # Data for the body of a POST request
100 var data: nullable HeaderMap is writable
101
102 # Raw body string
103 #
104 # Set this value to send raw data instead of the POST formatted `data`.
105 #
106 # If `data` is set, the body will not be sent.
107 var body: nullable String is writable
108
109 # Header content of the request
110 var headers: nullable HeaderMap is writable
111
112 # Delegates to customize the behavior when running `execute`
113 var delegate: nullable CurlCallbacks is writable
114
115 # Set the user agent for all following HTTP requests
116 var user_agent: nullable String is writable
117
118 # Set the Unix domain socket path to use
119 #
120 # When not null, enables using a Unix domain socket
121 # instead of a TCP connection and DNS hostname resolution.
122 var unix_socket_path: nullable String is writable
123
124 # The HTTP method, GET by default
125 #
126 # Must be a capitalized string with request name complying with RFC7231
127 var method: String = "GET" is optional, writable
128
129 # Execute HTTP request
130 #
131 # By default, the response body is returned in an instance of `CurlResponse`.
132 # This behavior can be customized by setting a custom `delegate`.
133 fun execute: CurlResponse
134 do
135 # Reset libcurl parameters as the lib is shared and options
136 # might affect requests from one another.
137 if not self.curl.is_ok then return answer_failure(0, "Curl instance is not correctly initialized")
138
139 var success_response = new CurlResponseSuccess
140 var callback_receiver: CurlCallbacks = success_response
141 var err : CURLCode
142
143 # Prepare request
144 err = prepare_request(callback_receiver)
145 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
146
147 # Perform request
148 var err_resp = perform
149 if err_resp != null then return err_resp
150
151 var st_code = self.curl.native.easy_getinfo_long(new CURLInfoLong.response_code)
152 if not st_code == null then success_response.status_code = st_code
153
154 return success_response
155 end
156
157 # Internal function that sets cURL options and request' parameters
158 private fun prepare_request(callback_receiver: CurlCallbacks) : CURLCode
159 do
160 var err
161
162 # cURL options and delegates
163 err = set_curl_options
164 if not err.is_ok then return err
165
166 # Callbacks
167 err = set_curl_callback(callback_receiver)
168 if not err.is_ok then return err
169
170 # HTTP Header
171 err = set_curl_http_header
172 if not err.is_ok then return err
173
174 # Set HTTP method and body
175 err = set_method
176 if not err.is_ok then return err
177 err = set_body
178
179 return err
180 end
181
182 # Set cURL parameters according to assigned HTTP method set in method
183 # attribute and body if the method allows it according to RFC7231
184 private fun set_method : CURLCode
185 do
186 var err : CURLCode
187
188 if self.method=="GET" then
189 err=self.curl.native.easy_setopt(new CURLOption.get, 1)
190
191 else if self.method=="POST" then
192 err=self.curl.native.easy_setopt(new CURLOption.post, 1)
193
194 else if self.method=="HEAD" then
195 err=self.curl.native.easy_setopt(new CURLOption.no_body,1)
196
197 else
198 err=self.curl.native.easy_setopt(new CURLOption.custom_request,self.method)
199 end
200 return err
201 end
202
203 # Set request's body
204 private fun set_body : CURLCode
205 do
206 var err
207 var data = self.data
208 var body = self.body
209
210 if data != null then
211 var postdatas = data.to_url_encoded(self.curl)
212 err = self.curl.native.easy_setopt(new CURLOption.postfields, postdatas)
213 if not err.is_ok then return err
214 else if body != null then
215 err = self.curl.native.easy_setopt(new CURLOption.postfields, body)
216 if not err.is_ok then return err
217 end
218 return new CURLCode.ok
219 end
220
221 # Set cURL options
222 # such as delegate, follow location, URL, user agent and address family
223 private fun set_curl_options : CURLCode
224 do
225 var err
226
227 err = self.curl.native.easy_setopt(new CURLOption.follow_location, 1)
228 if not err.is_ok then return err
229
230 err = self.curl.native.easy_setopt(new CURLOption.url, url)
231 if not err.is_ok then return err
232
233 var user_agent = user_agent
234 if user_agent != null then
235 err = curl.native.easy_setopt(new CURLOption.user_agent, user_agent)
236 if not err.is_ok then return err
237 end
238
239 var unix_socket_path = unix_socket_path
240 if unix_socket_path != null then
241 err = self.curl.native.easy_setopt(new CURLOption.unix_socket_path, unix_socket_path)
242 if not err.is_ok then return err
243 end
244 return err
245 end
246
247 # Set cURL callback
248 private fun set_curl_callback(callback_receiver : CurlCallbacks) : CURLCode
249 do
250 var err
251
252 if self.delegate != null then callback_receiver = self.delegate.as(not null)
253
254 err = self.curl.native.register_callback_header(callback_receiver)
255 if not err.is_ok then return err
256
257 err = self.curl.native.register_callback_body(callback_receiver)
258 if not err.is_ok then return err
259
260 return err
261 end
262
263 # Set cURL request header according to attribute headers
264 private fun set_curl_http_header : CURLCode
265 do
266 var headers = self.headers
267 if headers != null then
268 var headers_joined = headers.join_pairs(": ")
269 var err = self.curl.native.easy_setopt(new CURLOption.httpheader, headers_joined.to_curlslist)
270 if not err.is_ok then return err
271 end
272 return new CURLCode.ok
273 end
274
275 # Download to file given resource
276 fun download_to_file(output_file_name: nullable String): CurlResponse
277 do
278 if not self.curl.is_ok then return answer_failure(0, "Curl instance is not correctly initialized")
279
280 var success_response = new CurlFileResponseSuccess
281
282 var callback_receiver: CurlCallbacks = success_response
283 if self.delegate != null then callback_receiver = self.delegate.as(not null)
284
285 var err
286
287 err = self.curl.native.easy_setopt(new CURLOption.follow_location, 1)
288 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
289
290 err = self.curl.native.easy_setopt(new CURLOption.url, url)
291 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
292
293 err = self.curl.native.register_callback_header(callback_receiver)
294 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
295
296 err = self.curl.native.register_callback_stream(callback_receiver)
297 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
298
299 var opt_name
300 if not output_file_name == null then
301 opt_name = output_file_name
302 else if not self.url.substring(self.url.length-1, self.url.length) == "/" then
303 opt_name = self.url.basename
304 else
305 return answer_failure(0, "Unable to extract file name, please specify one")
306 end
307
308 success_response.file = new FileWriter.open(opt_name)
309 if not success_response.file.is_writable then
310 return answer_failure(0, "Unable to create associated file")
311 end
312
313 var err_resp = perform
314 if err_resp != null then return err_resp
315
316 var st_code = self.curl.native.easy_getinfo_long(new CURLInfoLong.response_code)
317 if not st_code == null then success_response.status_code = st_code
318
319 var speed = self.curl.native.easy_getinfo_double(new CURLInfoDouble.speed_download)
320 if not speed == null then success_response.speed_download = speed
321
322 var size = self.curl.native.easy_getinfo_double(new CURLInfoDouble.size_download)
323 if not size == null then success_response.size_download = size
324
325 var time = self.curl.native.easy_getinfo_double(new CURLInfoDouble.total_time)
326 if not time == null then success_response.total_time = time
327
328 success_response.file.close
329
330 return success_response
331 end
332 end
333
334
335 # CURL Mail Request
336 #
337 # ~~~
338 # # Craft mail
339 # var mail = new CurlMail("sender@example.org",
340 # to=["to@example.org"], cc=["bob@example.org"])
341 #
342 # mail.headers_body["Content-Type:"] = """text/html; charset="UTF-8""""
343 # mail.headers_body["Content-Transfer-Encoding:"] = "quoted-printable"
344 #
345 # mail.body = "<h1>Here you can write HTML stuff.</h1>"
346 # mail.subject = "Hello From My Nit Program"
347 #
348 # # Set mail server
349 # var error = mail.set_outgoing_server("smtps://smtp.example.org:465",
350 # "user@example.org", "mypassword")
351 # if error != null then
352 # print "Mail Server Error: {error}"
353 # exit 0
354 # end
355 #
356 # # Send
357 # error = mail.execute
358 # if error != null then
359 # print "Transfer Error: {error}"
360 # exit 0
361 # end
362 # ~~~
363 class CurlMail
364 super CurlRequest
365 super NativeCurlCallbacks
366
367 # Address of the sender
368 var from: nullable String is writable
369
370 # Main recipients
371 var to: nullable Array[String] is writable
372
373 # Subject line
374 var subject: nullable String is writable
375
376 # Text content
377 var body: nullable String is writable
378
379 # CC recipients
380 var cc: nullable Array[String] is writable
381
382 # BCC recipients (hidden from other recipients)
383 var bcc: nullable Array[String] is writable
384
385 # HTTP header
386 var headers = new HeaderMap is lazy, writable
387
388 # Content header
389 var headers_body = new HeaderMap is lazy, writable
390
391 # Protocols supported to send mail to a server
392 #
393 # Default value at `["smtp", "smtps"]`
394 var supported_outgoing_protocol = ["smtp", "smtps"]
395
396 # Helper method to add pair values to mail content while building it (ex: "To:", "address@mail.com")
397 private fun add_pair_to_content(str: String, att: String, val: nullable String): String
398 do
399 if val != null then return "{str}{att}{val}\n"
400 return "{str}{att}\n"
401 end
402
403 # Helper method to add entire list of pairs to mail content
404 private fun add_pairs_to_content(content: String, pairs: HeaderMap): String
405 do
406 for h_key, h_val in pairs do content = add_pair_to_content(content, h_key, h_val)
407 return content
408 end
409
410 # Check for host and protocol availability
411 private fun is_supported_outgoing_protocol(host: String): CURLCode
412 do
413 var host_reach = host.split_with("://")
414 if host_reach.length > 1 and supported_outgoing_protocol.has(host_reach[0]) then return once new CURLCode.ok
415 return once new CURLCode.unsupported_protocol
416 end
417
418 # Configure server host and user credentials if needed.
419 fun set_outgoing_server(host: String, user: nullable String, pwd: nullable String): nullable CurlResponseFailed
420 do
421 # Check Curl initialisation
422 if not self.curl.is_ok then return answer_failure(0, "Curl instance is not correctly initialized")
423
424 var err
425
426 # Host & Protocol
427 err = is_supported_outgoing_protocol(host)
428 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
429
430 err = self.curl.native.easy_setopt(new CURLOption.url, host)
431 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
432
433 # Credentials
434 if not user == null and not pwd == null then
435 err = self.curl.native.easy_setopt(new CURLOption.username, user)
436 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
437
438 err = self.curl.native.easy_setopt(new CURLOption.password, pwd)
439 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
440 end
441
442 return null
443 end
444
445 # Execute Mail request with settings configured through attribute
446 fun execute: nullable CurlResponseFailed
447 do
448 if not self.curl.is_ok then return answer_failure(0, "Curl instance is not correctly initialized")
449
450 var lines = new Array[String]
451
452 # Headers
453 var headers = self.headers
454 if not headers.is_empty then
455 for k, v in headers do lines.add "{k}{v}"
456 end
457
458 # Recipients
459 var all_recipients = new Array[String]
460 var to = self.to
461 if to != null and to.length > 0 then
462 lines.add "To:{to.join(",")}"
463 all_recipients.append to
464 end
465
466 var cc = self.cc
467 if cc != null and cc.length > 0 then
468 lines.add "Cc:{cc.join(",")}"
469 all_recipients.append cc
470 end
471
472 var bcc = self.bcc
473 if bcc != null and bcc.length > 0 then all_recipients.append bcc
474
475 if all_recipients.is_empty then return answer_failure(0, "There must be at lease one recipient")
476
477 var err = self.curl.native.easy_setopt(new CURLOption.follow_location, 1)
478 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
479
480 err = self.curl.native.easy_setopt(new CURLOption.mail_rcpt, all_recipients.to_curlslist)
481 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
482
483 # From
484 var from = self.from
485 if not from == null then
486 lines.add "From:{from}"
487
488 err = self.curl.native.easy_setopt(new CURLOption.mail_from, from)
489 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
490 end
491
492 # Subject
493 var subject = self.subject
494 if subject == null then subject = "" # Default
495 lines.add "Subject: {subject}"
496
497 # Headers body
498 var headers_body = self.headers_body
499 if not headers_body.is_empty then
500 for k, v in headers_body do lines.add "{k}{v}"
501 end
502
503 # Body
504 var body = self.body
505 if body == null then body = "" # Default
506
507 lines.add ""
508 lines.add body
509 lines.add ""
510
511 err = self.curl.native.register_callback_read(self)
512 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
513
514 var content = lines.join("\n")
515 err = self.curl.native.register_read_datas_callback(self, content)
516 if not err.is_ok then return answer_failure(err.to_i, err.to_s)
517
518 var err_resp = perform
519 if err_resp != null then return err_resp
520
521 return null
522 end
523 end
524
525 # Callbacks Interface, allow you to manage in your way the different streams
526 interface CurlCallbacks
527 super NativeCurlCallbacks
528 end
529
530 # Abstract Curl request response
531 abstract class CurlResponse
532 end
533
534 # Failed Response Class returned when errors during configuration are raised
535 class CurlResponseFailed
536 super CurlResponse
537
538 # Curl error code
539 var error_code: Int
540
541 # Curl error message
542 var error_msg: String
543
544 redef fun to_s do return "{error_msg} ({error_code})"
545 end
546
547 # Success Abstract Response Success Class
548 abstract class CurlResponseSuccessIntern
549 super CurlCallbacks
550 super CurlResponse
551
552 var headers = new HashMap[String, String]
553
554 # Receive headers from request due to headers callback registering
555 redef fun header_callback(line)
556 do
557 var splitted = line.split_with(':')
558 if splitted.length > 1 then
559 var key = splitted.shift
560 self.headers[key] = splitted.to_s
561 end
562 end
563 end
564
565 # Success Response Class of a basic response
566 class CurlResponseSuccess
567 super CurlResponseSuccessIntern
568
569 # Server HTTP response code
570 var status_code = 0
571
572 # Response body as a `String`
573 var body_str = ""
574
575 # Accept part of the response body
576 redef fun body_callback(line) do self.body_str += line
577 end
578
579 # Success Response Class of a downloaded File
580 class CurlFileResponseSuccess
581 super CurlResponseSuccessIntern
582
583 # Server HTTP response code
584 var status_code = 0
585
586 var speed_download = 0.0
587 var size_download = 0.0
588 var total_time = 0.0
589
590 private var file: nullable FileWriter = null
591
592 # Receive bytes stream from request due to stream callback registering
593 redef fun stream_callback(buffer)
594 do
595 file.write buffer
596 end
597 end
598
599 # Pseudo map associating `String` to `String` for HTTP exchanges
600 #
601 # This structure differs from `Map` as each key can have multiple associations
602 # and the order of insertion is important to some services.
603 class HeaderMap
604 private var array = new Array[Couple[String, String]]
605
606 # Add a `value` associated to `key`
607 fun []=(key, value: String)
608 do
609 array.add new Couple[String, String](key, value)
610 end
611
612 # Get a list of the keys associated to `key`
613 fun [](k: String): Array[String]
614 do
615 var res = new Array[String]
616 for c in array do if c.first == k then res.add c.second
617 return res
618 end
619
620 # Iterate over all the associations in `self`
621 fun iterator: MapIterator[String, String] do return new HeaderMapIterator(self)
622
623 # Get `self` as a single string for HTTP POST
624 #
625 # Require: `curl.is_ok`
626 private fun to_url_encoded(curl: Curl): String
627 do
628 assert curl.is_ok
629
630 var lines = new Array[String]
631 for k, v in self do
632 if k.length == 0 then continue
633
634 k = curl.native.escape(k)
635 v = curl.native.escape(v)
636 lines.add "{k}={v}"
637 end
638 return lines.join("&")
639 end
640
641 # Concatenate couple of 'key value' separated by 'sep' in Array
642 fun join_pairs(sep: String): Array[String]
643 do
644 var col = new Array[String]
645 for k, v in self do col.add("{k}{sep}{v}")
646 return col
647 end
648
649 # Number of values in `self`
650 fun length: Int do return array.length
651
652 # Is this map empty?
653 fun is_empty: Bool do return array.is_empty
654 end
655
656 private class HeaderMapIterator
657 super MapIterator[String, String]
658
659 var map: HeaderMap
660 var iterator: Iterator[Couple[String, String]] = map.array.iterator is lazy
661
662 redef fun is_ok do return self.iterator.is_ok
663 redef fun next do self.iterator.next
664 redef fun item do return self.iterator.item.second
665 redef fun key do return self.iterator.item.first
666 end