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