File Coverage

blib/lib/Net/Twitter/Lite.pm
Criterion Covered Total %
statement 209 280 74.6
branch 59 114 51.7
condition 28 58 48.2
subroutine 32 41 78.0
pod 7 8 87.5
total 335 501 66.8


line stmt bran cond sub pod time code
1             package Net::Twitter::Lite;
2             our $VERSION = '0.12008';
3 11     11   179153 use 5.005;
  11         46  
4 11     10   85 use warnings;
  10         20  
  10         381  
5 10     9   57 use strict;
  9         13  
  9         237  
6              
7             =head1 NAME
8              
9             Net::Twitter::Lite - A perl library for Twitter's API v1
10              
11             =head1 VERSION
12              
13             version 0.12008
14              
15             =cut
16              
17 9     9   53 use Carp;
  9         14  
  9         769  
18 9     9   3249 use URI::Escape;
  9         9779  
  9         600  
19 9     9   5145 use JSON;
  9         93623  
  9         55  
20 9     9   7255 use HTTP::Request::Common;
  9         137873  
  9         700  
21 9     9   5133 use Net::Twitter::Lite::Error;
  9         51  
  9         283  
22 9     9   5335 use Encode qw/encode_utf8/;
  9         80117  
  9         736  
23 9     9   4551 use Net::Twitter::Lite::WrapResult;
  9         21  
  9         27600  
24              
25             sub twitter_api_def_from () { 'Net::Twitter::Lite::API::V1' }
26             sub _default_api_url () { 'http://api.twitter.com/1' }
27             sub _default_searchapiurl () { 'http://search.twitter.com' }
28             sub _default_search_trends_api_url () { 'http://api.twitter.com/1' }
29             sub _default_lists_api_url () { 'http://api.twitter.com/1' }
30              
31             my $json_handler = JSON->new->utf8;
32              
33             sub new {
34 11     11 1 136076 my ($class, %args) = @_;
35              
36 11 100       148 $class->can('verify_credentials') || $class->build_api_methods;
37              
38 11         35 my $netrc = delete $args{netrc};
39             my $new = bless {
40             apiurl => $class->_default_api_url,
41             searchapiurl => $class->_default_searchapiurl,
42             search_trends_api_url => $class->_default_search_trends_api_url,
43             lists_api_url => $class->_default_lists_api_url,
44             apirealm => 'Twitter API',
45 11 50 33     462 $args{identica} ? ( apiurl => 'http://identi.ca/api' ) : (),
      33        
46             useragent => (ref $class || $class) . "/$VERSION (Perl)",
47             clientname => (ref $class || $class),
48             clientver => $VERSION,
49             clienturl => 'http://search.cpan.org/dist/Net-Twitter-Lite/',
50             source => 'twitterpm',
51             useragent_class => 'LWP::UserAgent',
52             useragent_args => {},
53             oauth_urls => {
54             request_token_url => "https://api.twitter.com/oauth/request_token",
55             authentication_url => "https://api.twitter.com/oauth/authenticate",
56             authorization_url => "https://api.twitter.com/oauth/authorize",
57             access_token_url => "https://api.twitter.com/oauth/access_token",
58             xauth_url => "https://api.twitter.com/oauth/access_token",
59             },
60             netrc_machine => 'api.twitter.com',
61             %args
62             }, $class;
63              
64 11 50       100 unless ( exists $new->{legacy_lists_api} ) {
65 0         0 $new->{legacy_lists_api} = 1;
66 0         0 carp
67 0         0 "For backwards compatibility @{[ __PACKAGE__ ]} uses the deprecated Lists API
68             endpoints and semantics. This default will be changed in a future version.
69             Please update your code to use the new lists semantics and pass
70             (legacy_lists_api => 0) to new.
71              
72             You can disable this warning, and keep backwards compatibility by passing
73             (legacy_lists_api => 1) to new. Be warned, however, that support for the
74             legacy endpoints will be removed in a future version and the default will
75             change to (legacy_lists_api => 0).";
76              
77             }
78              
79 11 100       40 if ( delete $args{ssl} ) {
80             $new->{$_} =~ s/^http:/https:/
81 4         75 for qw/apiurl searchapiurl search_trends_api_url lists_api_url/;
82             }
83              
84             # get username and password from .netrc
85 11 50       38 if ( $netrc ) {
86 0 0       0 eval { require Net::Netrc; 1 }
  0         0  
  0         0  
87             || croak "Net::Netrc is required for the netrc option";
88              
89 0 0       0 my $host = $netrc eq '1' ? $new->{netrc_machine} : $netrc;
90 0   0     0 my $nrc = Net::Netrc->lookup($host)
91             || croak "No .netrc entry for $host";
92              
93 0         0 @{$new}{qw/username password/} = $nrc->lpa;
  0         0  
94             }
95              
96 11   66     62 $new->{ua} ||= do {
97 9     7   896 eval "use $new->{useragent_class}";
  7         77  
  7         13  
  7         135  
98 9 50       37 croak $@ if $@;
99              
100 9         47 $new->{useragent_class}->new(%{$new->{useragent_args}});
  9         77  
101             };
102              
103 11         25959 $new->{ua}->agent($new->{useragent});
104 11         706 $new->{ua}->default_header('X-Twitter-Client' => $new->{clientname});
105 11         607 $new->{ua}->default_header('X-Twitter-Client-Version' => $new->{clientver});
106 11         509 $new->{ua}->default_header('X-Twitter-Client-URL' => $new->{clienturl});
107 11         504 $new->{ua}->env_proxy;
108              
109             $new->{_authenticator} = exists $new->{consumer_key}
110 11 100       46040 ? '_oauth_authenticated_request'
111             : '_basic_authenticated_request';
112              
113 4         22 $new->credentials(@{$new}{qw/username password/})
114 11 50 66     75 if exists $new->{username} && exists $new->{password};
115              
116 11         161 return $new;
117             }
118              
119             sub credentials {
120 5     5 1 759 my $self = shift;
121 5         12 my ($username, $password) = @_;
122              
123 5 50       22 croak "exected a username and password" unless @_ == 2;
124 5 50       18 croak "OAuth authentication is in use" if exists $self->{consumer_key};
125              
126 5         14 $self->{username} = $username;
127 5         7 $self->{password} = $password;
128              
129 5         38 my $uri = URI->new($self->{apiurl});
130 5         27699 my $netloc = join ':', $uri->host, $uri->port;
131              
132 5         604 $self->{ua}->credentials($netloc, $self->{apirealm}, $username, $password);
133             }
134              
135             # This is a hack. Rather than making Net::OAuth an install requirement for
136             # Net::Twitter::Lite, require it at runtime if any OAuth methods are used. It
137             # simply returns the string 'Net::OAuth' after successfully requiring
138             # Net::OAuth.
139             sub _oauth {
140 1     1   2 my $self = shift;
141              
142 1   33     5 return $self->{_oauth} ||= do {
143 1         76 eval "use Net::OAuth 0.25";
144 1 50       4 croak "Install Net::OAuth 0.25 or later for OAuth support" if $@;
145              
146 1         51 eval '$Net::OAuth::PROTOCOL_VERSION = Net::OAuth::PROTOCOL_VERSION_1_0A';
147 1 50       5 die $@ if $@;
148              
149 1         7 'Net::OAuth';
150             };
151             }
152              
153             # simple check to see if we have access tokens; does not check to see if they are valid
154             sub authorized {
155 1     1 1 2 my $self = shift;
156              
157 1   33     7 return defined $self->{access_token} && $self->{access_token_secret};
158             }
159              
160             # get the athorization or authentication url
161             sub _get_auth_url {
162 0     0   0 my ($self, $which_url, %params ) = @_;
163              
164 0         0 $self->_request_request_token(%params);
165              
166 0         0 my $uri = $self->$which_url;
167 0         0 $uri->query_form(oauth_token => $self->request_token);
168 0         0 return $uri;
169             }
170              
171             # get the authentication URL from Twitter
172 0     0 1 0 sub get_authentication_url { return shift->_get_auth_url(authentication_url => @_) }
173              
174             # get the authorization URL from Twitter
175 0     0 1 0 sub get_authorization_url { return shift->_get_auth_url(authorization_url => @_) }
176              
177             # common portion of all oauth requests
178             sub _make_oauth_request {
179 1     1   6 my ($self, $type, %params) = @_;
180              
181             my $request = $self->_oauth->request($type)->new(
182             version => '1.0',
183             consumer_key => $self->{consumer_key},
184             consumer_secret => $self->{consumer_secret},
185 1         3 request_method => 'GET',
186             signature_method => 'HMAC-SHA1',
187             timestamp => time,
188             nonce => time ^ $$ ^ int(rand 2**32),
189             %params,
190             );
191              
192 1         10686 $request->sign;
193              
194 1         7148 return $request;
195             }
196              
197             # called by get_authorization_url to obtain request tokens
198             sub _request_request_token {
199 0     0   0 my ($self, %params) = @_;
200              
201 0         0 my $uri = $self->request_token_url;
202 0   0     0 $params{callback} ||= 'oob';
203 0         0 my $request = $self->_make_oauth_request(
204             'request token',
205             request_url => $uri,
206             %params,
207             );
208              
209 0         0 my $res = $self->{ua}->get($request->to_url);
210 0 0       0 die "GET $uri failed: ".$res->status_line
211             unless $res->is_success;
212              
213             # reuse $uri to extract parameters from the response content
214 0         0 $uri->query($res->content);
215 0         0 my %res_param = $uri->query_form;
216              
217 0         0 $self->request_token($res_param{oauth_token});
218 0         0 $self->request_token_secret($res_param{oauth_token_secret});
219             }
220              
221             # exchange request tokens for access tokens; call with (verifier => $verifier)
222             sub request_access_token {
223 0     0 1 0 my ($self, %params ) = @_;
224              
225 0         0 my $uri = $self->access_token_url;
226 0         0 my $request = $self->_make_oauth_request(
227             'access token',
228             request_url => $uri,
229             token => $self->request_token,
230             token_secret => $self->request_token_secret,
231             %params, # verifier => $verifier
232             );
233              
234 0         0 my $res = $self->{ua}->get($request->to_url);
235 0 0       0 die "GET $uri failed: ".$res->status_line
236             unless $res->is_success;
237              
238             # discard request tokens, they're no longer valid
239 0         0 delete $self->{request_token};
240 0         0 delete $self->{request_token_secret};
241              
242             # reuse $uri to extract parameters from content
243 0         0 $uri->query($res->content);
244 0         0 my %res_param = $uri->query_form;
245              
246             return (
247             $self->access_token($res_param{oauth_token}),
248             $self->access_token_secret($res_param{oauth_token_secret}),
249             $res_param{user_id},
250             $res_param{screen_name},
251 0         0 );
252             }
253              
254             # exchange username and password for access tokens
255             sub xauth {
256 0     0 1 0 my ( $self, $username, $password ) = @_;
257              
258 0         0 my $uri = $self->xauth_url;
259 0         0 my $request = $self->_make_oauth_request(
260             'XauthAccessToken',
261             request_url => $uri,
262             x_auth_username => $username,
263             x_auth_password => $password,
264             x_auth_mode => 'client_auth',
265             );
266              
267 0         0 my $res = $self->{ua}->get($request->to_url);
268 0 0       0 die "GET $uri failed: ".$res->status_line
269             unless $res->is_success;
270              
271             # reuse $uri to extract parameters from content
272 0         0 $uri->query($res->content);
273 0         0 my %res_param = $uri->query_form;
274              
275             return (
276             $self->access_token($res_param{oauth_token}),
277             $self->access_token_secret($res_param{oauth_token_secret}),
278             $res_param{user_id},
279             $res_param{screen_name},
280 0         0 );
281             }
282              
283             # common call for both Basic Auth and OAuth
284             sub _authenticated_request {
285 155     155   192 my $self = shift;
286              
287 155         287 my $authenticator = $self->{_authenticator};
288 155         463 $self->$authenticator(@_);
289             }
290              
291             sub _encode_args {
292 154     154   168 my $args = shift;
293              
294             # Values need to be utf-8 encoded. Because of a perl bug, exposed when
295             # client code does "use utf8", keys must also be encoded.
296             # see: http://www.perlmonks.org/?node_id=668987
297             # and: http://perl5.git.perl.org/perl.git/commit/eaf7a4d2
298 154 50       3660 return { map { utf8::upgrade($_) unless ref($_); $_ } %$args };
  256         733  
  256         557  
299             }
300              
301             sub _oauth_authenticated_request {
302 1     1   3 my ($self, $http_method, $uri, $args, $authenticate) = @_;
303              
304 1         2 delete $args->{source}; # not necessary with OAuth requests
305              
306 1   50     7 my $content_type = delete $args->{-content_type} || '';
307 1   33     6 my $is_multipart = $content_type eq 'form-data' || grep { ref } %$args;
308              
309 1         2 my $msg;
310 1 50 33     5 if ( $authenticate && $self->authorized ) {
    0          
    0          
311 1         2 local $Net::OAuth::SKIP_UTF8_DOUBLE_ENCODE_CHECK = 1;
312              
313 1 50       5 my $request = $self->_make_oauth_request(
314             'protected resource',
315             request_url => $uri,
316             request_method => $http_method,
317             token => $self->access_token,
318             token_secret => $self->access_token_secret,
319             extra_params => $is_multipart ? {} : $args,
320             );
321              
322 1 50       10 if ( $http_method =~ /^(?:GET|DELETE)$/ ) {
    0          
323 1         7 $msg = HTTP::Request->new($http_method, $request->to_url);
324             }
325             elsif ( $http_method eq 'POST' ) {
326 0 0       0 $msg = $is_multipart
327             ? POST($request->request_url,
328             Authorization => $request->to_authorization_header,
329             Content_Type => 'form-data',
330             Content => [ %$args ],
331             )
332             : POST($$uri, Content => $request->to_post_body)
333             ;
334             }
335             else {
336 0         0 croak "unexpected http_method: $http_method";
337             }
338             }
339             elsif ( $http_method eq 'GET' ) {
340 0         0 $uri->query_form($args);
341 0         0 $args = {};
342 0         0 $msg = GET($uri);
343             }
344             elsif ( $http_method eq 'POST' ) {
345 0         0 my $encoded_args = { %$args };
346 0         0 _encode_args($encoded_args);
347 0         0 $msg = $self->_mk_post_msg($uri, $args);
348             }
349             else {
350 0         0 croak "unexpected http_method: $http_method";
351             }
352              
353 1         734 return $self->{ua}->request($msg);
354             }
355              
356             sub _basic_authenticated_request {
357 154     154   260 my ($self, $http_method, $uri, $args, $authenticate) = @_;
358              
359 154         275 _encode_args($args);
360              
361 154         324 my $msg;
362 154 100       632 if ( $http_method =~ /^(?:GET|DELETE)$/ ) {
    50          
363 90         310 $uri->query_form($args);
364 90         4805 $msg = HTTP::Request->new($http_method, $uri);
365             }
366             elsif ( $http_method eq 'POST' ) {
367 64         173 $msg = $self->_mk_post_msg($uri, $args);
368             }
369             else {
370 0         0 croak "unexpected HTTP method: $http_method";
371             }
372              
373 154 50 66     17734 if ( $authenticate && $self->{username} && $self->{password} ) {
      66        
374 48         105 $msg->headers->authorization_basic(@{$self}{qw/username password/});
  48         299  
375             }
376              
377 154         8541 return $self->{ua}->request($msg);
378             }
379              
380             sub _mk_post_msg {
381 64     64   87 my ($self, $uri, $args) = @_;
382              
383 64 50       145 if ( grep { ref } values %$args ) {
  73         182  
384             # if any of the arguments are (array) refs, use form-data
385 0         0 return POST($uri, Content_Type => 'form-data', Content => [ %$args ]);
386             }
387             else {
388             # There seems to be a bug introduced by Twitter about 2013-02-25: If
389             # post arguments are uri encoded exactly the same way the OAuth spec
390             # requires base signature string encoding, Twitter chokes and throws a
391             # 401. This seems to be a violation of the OAuth spec on Twitter's
392             # part. The specifically states the more stringent URI encoding is for
393             # consistent signature generation and *only* applies to encoding the
394             # base signature string and Authorization header.
395              
396 64         71 my @pairs;
397 64         249 while ( my ($k, $v) = each %$args ) {
398 73         3110 push @pairs, join '=', map URI::Escape::uri_escape_utf8($_, '^A-Za-z0-9\-\._~'), $k, $v;
399             }
400              
401 64         3639 my $content = join '&', @pairs;
402 64         251 return POST($uri, Content => $content);
403             }
404             }
405              
406             sub build_api_methods {
407 8     8 0 16 my $class = shift;
408              
409 8         27 my $api_def_module = $class->twitter_api_def_from;
410 8 50       607 eval "require $api_def_module" or die $@;
411 8         122 my $api_def = $api_def_module->api_def;
412              
413             my $with_url_arg = sub {
414 0     0   0 my ($path, $args) = @_;
415              
416 0 0       0 if ( defined(my $id = delete $args->{id}) ) {
417 0         0 $path .= uri_escape($id);
418             }
419             else {
420 0         0 chop($path);
421             }
422 0         0 return $path;
423 8         211 };
424              
425 8         76 while ( @$api_def ) {
426 20         45 my $api = shift @$api_def;
427 20         37 my $api_name = shift @$api;
428 20         30 my $methods = shift @$api;
429              
430 20         41 for my $method ( @$methods ) {
431 930         1183 my $name = shift @$method;
432 930         784 my %options = %{ shift @$method };
  930         5359  
433              
434 930         6044 my ($arg_names, $path) = @options{qw/required path/};
435             $arg_names = $options{params}
436 930 100 100     1898 if @$arg_names == 0 && @{$options{params}} == 1;
  454         1452  
437              
438 930 50   0   2790 my $modify_path = $path =~ s,/id$,/, ? $with_url_arg : sub { $_[0] };
  0         0  
439              
440             my $code = sub {
441 176     176   153355 my $self = shift;
442              
443             # copy callers args since we may add ->{source}
444 176 100       545 my $args = ref $_[-1] eq 'HASH' ? { %{pop @_} } : {};
  99         314  
445 176 50       586 if ( my $content_type = $options{content_type} ) {
446 0         0 $args->{-content_type} = $options{content_type};
447             }
448              
449 176 100 66     1614 if ( (my $legacy_method = $self->can("legacy_$name")) && (
    100          
450             exists $$args{-legacy_lists_api}
451             ? delete $$args{-legacy_lists_api}
452             : $self->{legacy_lists_api} ) ) {
453 21         57 return $self->$legacy_method(@_, $args);
454             }
455              
456             # just in case it's included where it shouldn't be:
457 155         420 delete $args->{-legacy_lists_api};
458              
459 155 50       433 croak sprintf "$name expected %d args", scalar @$arg_names
460             if @_ > @$arg_names;
461              
462             # promote positional args to named args
463 155         398 for ( my $i = 0; @_; ++$i ) {
464 73         123 my $param = $arg_names->[$i];
465             croak "duplicate param $param: both positional and named"
466 73 50       187 if exists $args->{$param};
467              
468 73         247 $args->{$param} = shift;
469             }
470              
471 155 100 33     406 $args->{source} ||= $self->{source} if $options{add_source};
472              
473             my $authenticate = exists $args->{authenticate}
474             ? delete $args->{authenticate}
475 155 100       409 : $options{authenticate};
476              
477             # promote boolean parameters
478 155         159 for my $boolean_arg ( @{ $options{booleans} } ) {
  155         397  
479 160 50       412 if ( exists $args->{$boolean_arg} ) {
480 0 0       0 next if $args->{$boolean_arg} =~ /^true|false$/;
481 0 0       0 $args->{$boolean_arg} = $args->{$boolean_arg} ? 'true' : 'false';
482             }
483             }
484              
485             # Workaround Twitter bug: any value passed for skip_user is treated as true.
486             # The only way to get 'false' is to not pass the skip_user at all.
487             delete $args->{skip_user} if exists $args->{skip_user}
488 155 50 33     414 && $args->{skip_user} eq 'false';
489              
490             # replace placeholder arguments
491 155         233 my $local_path = $path;
492              
493             # remove optional trailing id
494 155 100       470 $local_path =~ s,/:id$,, unless exists $args->{id};
495 155 50       378 $local_path =~ s/:(\w+)/delete $args->{$1}
  61         282  
496             or croak "required arg '$1' missing"/eg;
497              
498             # stringify lists
499 155         287 for ( qw/screen_name user_id/ ) {
500 3         11 $args->{$_} = join(',' => @{ $args->{$_} })
501 310 100       710 if ref $args->{$_} eq 'ARRAY';
502             }
503              
504             my $uri = URI->new($self->{$options{base_url_method}}
505 155         1017 . "/$local_path.json");
506              
507             return $self->_parse_result(
508             $self->_authenticated_request(
509 155         56989 $options{method}, $uri, $args, $authenticate
510             )
511             );
512 930         7968 };
513              
514 9     9   87 no strict 'refs';
  9         15  
  9         2006  
515 930         837 $name = $_, *{"$class\::$_"} = $code for $name, @{$options{aliases}};
  930         1672  
  1114         6735  
516             }
517             }
518              
519             # catch expected error and promote it to an undef
520 8         30 for ( qw/list_members is_list_member list_subscribers is_list_subscriber
521             legacy_list_members legacy_is_list_member legacy_list_subscribers legacy_is_list_subscriber/ ) {
522 64 100       323 my $orig = $class->can($_) or next;
523              
524             my $code = sub {
525 11     11   10666 my $r = eval { $orig->(@_) };
  11         47  
526 11 50       27 if ( $@ ) {
527 0 0       0 return if $@ =~ /The specified user is not a (?:memb|subscrib)er of this list/;
528              
529 0         0 die $@;
530             }
531              
532 11         19 return $r;
533 56         132 };
534              
535 9     9   131 no strict 'refs';
  9         15  
  9         413  
536 9     9   46 no warnings 'redefine';
  9         12  
  9         771  
537 56         72 *{"$class\::$_"} = $code;
  56         181  
538             }
539              
540             # OAuth token accessors
541 8         20 for my $method ( qw/
542             access_token
543             access_token_secret
544             request_token
545             request_token_secret
546             / ) {
547 9     9   40 no strict 'refs';
  9         14  
  9         871  
548 32         191 *{"$class\::$method"} = sub {
549 4     4   106 my $self = shift;
550              
551 4 100       22 $self->{$method} = shift if @_;
552 4         17 return $self->{$method};
553 32         101 };
554             }
555              
556             # OAuth url accessors
557 8         27 for my $method ( qw/
558             request_token_url
559             authentication_url
560             authorization_url
561             access_token_url
562             xauth_url
563             / ) {
564 9     9   62 no strict 'refs';
  9         14  
  9         3456  
565 40         210 *{"$class\::$method"} = sub {
566 0     0   0 my $self = shift;
567              
568 0 0       0 $self->{oauth_urls}{$method} = shift if @_;
569 0         0 return URI->new($self->{oauth_urls}{$method});
570 40         94 };
571             }
572              
573             }
574              
575             sub _from_json {
576 155     155   280 my ($self, $json) = @_;
577              
578 155         222 return eval { $json_handler->decode($json) };
  155         1262  
579             }
580              
581             sub _parse_result {
582 155     155   107758 my ($self, $res) = @_;
583              
584             # workaround for Laconica API returning bools as strings
585             # (Fixed in Laconi.ca 0.7.4)
586 155         417 my $content = $res->content;
587 155         1587 $content =~ s/^"(true|false)"$/$1/;
588              
589 155         374 my $obj = $self->_from_json($content);
590              
591             # Twitter sometimes returns an error with status code 200
592 155 50 66     1232 if ( $obj && ref $obj eq 'HASH' && exists $obj->{error} ) {
      66        
593 0         0 die Net::Twitter::Lite::Error->new(twitter_error => $obj, http_response => $res);
594             }
595              
596 155 100 66     435 if ( $res->is_success && defined $obj ) {
597 151 100       1501 if ( $self->{wrap_result} ) {
598 17         52 $obj = Net::Twitter::Lite::WrapResult->new($obj, $res);
599             }
600 151         1040 return $obj;
601             }
602              
603 4         80 my $error = Net::Twitter::Lite::Error->new(http_response => $res);
604 4 50       12 $error->twitter_error($obj) if ref $obj;
605              
606 4         30 die $error;
607             }
608              
609             1;