1package Twitter::API;
2# ABSTRACT: A Twitter REST API library for Perl
3
4our $VERSION = '1.0006';
5use 5.14.1;
6use Moo;
7use Carp;
8use Digest::SHA;
9use Encode qw/encode_utf8/;
10use HTTP::Request;
11use HTTP::Request::Common qw/POST/;
12use JSON::MaybeXS ();
13use Module::Runtime qw/use_module/;
14use Ref::Util qw/is_arrayref is_ref/;
15use Try::Tiny;
16use Twitter::API::Context;
17use Twitter::API::Error;
18use URI;
19use URL::Encode ();
20use WWW::OAuth;
21use namespace::clean;
22
23with qw/MooX::Traits/;
24sub _trait_namespace { 'Twitter::API::Trait' }
25
26has api_version => (
27    is      => 'ro',
28    default => sub { '1.1' },
29);
30
31has api_ext => (
32    is      => 'ro',
33    default => sub { '.json' },
34);
35
36has [ qw/consumer_key consumer_secret/ ] => (
37    is       => 'ro',
38    required => 1,
39);
40
41has [ qw/access_token access_token_secret/ ] => (
42    is        => 'rw',
43    predicate => 1,
44    clearer   => 1,
45);
46
47# The secret is no good without the token.
48after clear_access_token => sub {
49    shift->clear_access_token_secret;
50};
51
52has api_url => (
53    is      => 'ro',
54    default => sub { 'https://api.twitter.com' },
55);
56
57has upload_url => (
58    is      => 'ro',
59    default => sub { 'https://upload.twitter.com' },
60);
61
62has agent => (
63    is      => 'ro',
64    default => sub {
65        (join('/', __PACKAGE__, $VERSION) =~ s/::/-/gr) . ' (Perl)';
66    },
67);
68
69has timeout => (
70    is      => 'ro',
71    default => sub { 10 },
72);
73
74has default_headers => (
75    is => 'ro',
76    default => sub {
77        my $agent = shift->agent;
78        {
79            user_agent               => $agent,
80            x_twitter_client         => $agent,
81            x_twitter_client_version => $VERSION,
82            x_twitter_client_url     => 'https://github.com/semifor/Twitter-API',
83        };
84    },
85);
86
87has user_agent => (
88    is      => 'ro',
89    lazy    => 1,
90    default => sub {
91        my $self = shift;
92
93        use_module 'HTTP::Thin';
94        HTTP::Thin->new(
95            timeout => $self->timeout,
96            agent   => $self->agent,
97        );
98    },
99    handles => {
100        send_request   => 'request',
101    },
102);
103
104has json_parser => (
105    is      => 'ro',
106    lazy    => 1,
107    default => sub { JSON::MaybeXS->new(utf8 => 1) },
108    handles => {
109        from_json => 'decode',
110        to_json   => 'encode',
111    },
112);
113
114around BUILDARGS => sub {
115    my ( $next, $class ) = splice @_, 0, 2;
116
117    my $args = $class->$next(@_);
118    croak 'use new_with_traits' if exists $args->{traits};
119
120    return $args;
121};
122
123sub get  { shift->request( get => @_ ) }
124sub post { shift->request( post => @_ ) }
125
126sub request {
127    my $self = shift;
128
129    my $c = Twitter::API::Context->new({
130        http_method => uc shift,
131        url         => shift,
132        args        => shift || {},
133        # shallow copy so we don't spoil the defaults
134        headers     => {
135            %{ $self->default_headers },
136            accept       => 'application/json',
137            content_type => 'application/json;charset=utf8',
138        },
139        extra_args  => \@_,
140    });
141
142    $self->extract_options($c);
143    $self->preprocess_args($c);
144    $self->preprocess_url($c);
145    $self->prepare_request($c);
146    $self->add_authorization($c);
147
148    # Allow early exit for things like Twitter::API::AnyEvent
149    $c->set_http_response($self->send_request($c) // return $c);
150
151    $self->inflate_response($c);
152    return wantarray ? ( $c->result, $c ) : $c->result;
153}
154
155sub extract_options {
156    my ( $self, $c ) = @_;
157
158    my $args = $c->args;
159    for ( keys %$args ) {
160        $c->set_option($1, delete $$args{$_}) if /^-(.+)/;
161    }
162}
163
164sub preprocess_args {
165    my ( $self, $c ) = @_;
166
167    if ( $c->http_method eq 'GET' ) {
168        $self->flatten_array_args($c->args);
169    }
170
171    # If any of the args are arrayrefs, we'll infer it's multipart/form-data
172    $c->set_option(multipart_form_data => 1) if
173        $c->http_method eq 'POST' && !!grep is_ref($_), values %{ $c->args };
174}
175
176sub preprocess_url {
177    my ( $self, $c ) = @_;
178
179    my $url = $c->url;
180    my $args = $c->args;
181    $url =~ s[:(\w+)][delete $$args{$1} // croak "missing arg $1"]eg;
182    $c->set_url($self->api_url_for($url));
183}
184
185sub prepare_request {
186    my ( $self, $c ) = @_;
187
188    # possibly override Accept header
189    $c->set_header(accept => $c->get_option('accept'))
190        if $c->has_option('accept');
191
192    # dispatch on HTTP method
193    my $http_method = $c->http_method;
194    my $prepare_method = join '_', 'mk', lc($http_method), 'request';
195    my $dispatch = $self->can($prepare_method)
196        || croak "unexpected HTTP method: $http_method";
197
198    my $req = $self->$dispatch($c);
199    $c->set_http_request($req);
200}
201
202sub mk_get_request {
203    shift->mk_simple_request(GET => @_);
204}
205
206sub mk_delete_request {
207    shift->mk_simple_request(DELETE => @_);
208}
209
210sub mk_post_request {
211    my ( $self, $c ) = @_;
212
213    if ( $c->get_option('multipart_form_data') ) {
214        return $self->mk_multipart_post($c);
215    }
216
217    if ( $c->has_option('to_json') ) {
218        return $self->mk_json_post($c);
219    }
220
221    return $self->mk_form_urlencoded_post($c);
222}
223
224sub mk_multipart_post {
225    my ( $self, $c ) = @_;
226
227    $c->set_header(content_type => 'multipart/form-data;charset=utf-8');
228    POST $c->url,
229        %{ $c->headers },
230        Content => [
231            map { is_ref($_) ? $_ : encode_utf8 $_ } %{ $c->args },
232        ];
233}
234
235sub mk_json_post {
236    my ( $self, $c ) = @_;
237
238    POST $c->url,
239        %{ $c->headers },
240        Content => $self->to_json($c->get_option('to_json'));
241}
242
243sub mk_form_urlencoded_post {
244    my ( $self, $c ) = @_;
245
246    $c->set_header(
247        content_type => 'application/x-www-form-urlencoded;charset=utf-8');
248    POST $c->url,
249        %{ $c->headers },
250        Content => $self->encode_args_string($c->args);
251}
252
253sub mk_simple_request {
254    my ( $self, $http_method, $c ) = @_;
255
256    my $uri = URI->new($c->url);
257    if ( my $encoded = $self->encode_args_string($c->args) ) {
258        $uri->query($encoded);
259    }
260
261    # HTTP::Message expects an arrayref, so transform
262    my $headers = [ %{ $c->headers } ];
263
264    return HTTP::Request->new($http_method, $uri, $headers);
265}
266
267sub add_authorization {
268    my ( $self, $c ) = @_;
269
270    my $req = $c->http_request;
271
272    my %cred = (
273        client_id     => $self->consumer_key,
274        client_secret => $self->consumer_secret,
275    );
276
277    my %oauth;
278    # only the token management methods set 'oauth_args'
279    if ( my $opt = $c->get_option('oauth_args') ) {
280        %oauth = %$opt;
281        $cred{token}        = delete $oauth{oauth_token};
282        $cred{token_secret} = delete $oauth{oauth_token_secret};
283    }
284    else {
285        # protected request; requires tokens
286        $cred{token} = $c->get_option('token')
287            // $self->access_token
288            // croak 'requires an oauth token';
289        $cred{token_secret} = $c->get_option('token_secret')
290            // $self->access_token_secret
291            // croak 'requires an oauth token secret';
292    }
293
294    WWW::OAuth->new(%cred)->authenticate($req, \%oauth);
295}
296
297around send_request => sub {
298    my ( $orig, $self, $c ) = @_;
299
300    $self->$orig($c->http_request);
301};
302
303sub inflate_response {
304    my ( $self, $c ) = @_;
305
306    my $res = $c->http_response;
307    my $data;
308    try {
309        if ( $res->content_type eq 'application/json' ) {
310            $data = $self->from_json($res->content);
311        }
312        elsif ( ( $res->content_length // 0 ) == 0 ) {
313            # E.g., 200 OK from media/metadata/create
314            $data = '';
315        }
316        elsif ( ($c->get_option('accept') // '') eq 'application/x-www-form-urlencoded' ) {
317
318            # Twitter sets Content-Type: text/html for /oauth/request_token and
319            # /oauth/access_token even though they return url encoded form
320            # data. So we'll decode based on what we expected when we set the
321            # Accept header. We don't want to assume form data when we didn't
322            # request it, because sometimes twitter returns 200 OK with actual
323            # HTML content. We don't want to decode and return that. It's an
324            # error. We'll just leave $data unset if we don't have a reasonable
325            # expectation of the content type.
326
327            $data = URL::Encode::url_params_mixed($res->content, 1);
328        }
329    }
330    catch {
331        # Failed to decode the response body, synthesize an error response
332        s/ at .* line \d+.*//s;  # remove file/line number
333        $res->code(500);
334        $res->status($_);
335    };
336
337    $c->set_result($data);
338    return if defined($data) && $res->is_success;
339
340    $self->process_error_response($c);
341}
342
343sub flatten_array_args {
344    my ( $self, $args ) = @_;
345
346    # transform arrays to comma delimited strings
347    for my $k ( keys %$args ) {
348        my $v = $$args{$k};
349        $$args{$k} = join ',' => @$v if is_arrayref($v);
350    }
351}
352
353sub encode_args_string {
354    my ( $self, $args ) = @_;
355
356    my @pairs;
357    for my $k ( sort keys %$args ) {
358        push @pairs, join '=', map $self->uri_escape($_), $k, $$args{$k};
359    }
360
361    join '&', @pairs;
362}
363
364sub uri_escape { URL::Encode::url_encode_utf8($_[1]) }
365
366sub process_error_response {
367    Twitter::API::Error->throw({ context => $_[1] });
368}
369
370sub api_url_for {
371    my $self = shift;
372
373    $self->_url_for($self->api_ext, $self->api_url, $self->api_version, @_);
374}
375
376sub upload_url_for {
377    my $self = shift;
378
379    $self->_url_for($self->api_ext, $self->upload_url, $self->api_version, @_);
380}
381
382sub oauth_url_for {
383    my $self = shift;
384
385    $self->_url_for('', $self->api_url, 'oauth', @_);
386}
387
388sub _url_for {
389    my ( $self, $ext, @parts ) = @_;
390
391    # If we already have a fully qualified URL, just return it
392    return $_[-1] if $_[-1] =~ m(^https?://);
393
394    my $url = join('/', @parts);
395    $url .= $ext if $ext;
396
397    return $url;
398}
399
400# OAuth handshake
401
402sub oauth_request_token {
403    my $self = shift;
404    my %args = @_ == 1 && is_ref($_[0]) ? %{ $_[0] } : @_;
405
406    my %oauth_args;
407    $oauth_args{oauth_callback} = delete $args{callback} // 'oob';
408    return $self->request(post => $self->oauth_url_for('request_token'), {
409        -accept     => 'application/x-www-form-urlencoded',
410        -oauth_args => \%oauth_args,
411        %args, # i.e. ( x_auth_access_type => 'read' )
412    });
413}
414
415sub _auth_url {
416    my ( $self, $endpoint ) = splice @_, 0, 2;
417    my %args = @_ == 1 && is_ref($_[0]) ? %{ $_[0] } : @_;
418
419    my $uri = URI->new($self->oauth_url_for($endpoint));
420    $uri->query_form(%args);
421    return $uri;
422};
423
424sub oauth_authentication_url { shift->_auth_url(authenticate => @_) }
425sub oauth_authorization_url  { shift->_auth_url(authorize    => @_) }
426
427sub oauth_access_token {
428    my $self = shift;
429    my %args = @_ == 1 && is_ref($_[0]) ? %{ $_[0] } : @_;
430
431    # We'll take 'em with or without the oauth_ prefix :)
432    my %oauth_args;
433    @oauth_args{map s/^(?!oauth_)/oauth_/r, keys %args} = values %args;
434
435    $self->request(post => $self->oauth_url_for('access_token'), {
436        -accept     => 'application/x-www-form-urlencoded',
437        -oauth_args => \%oauth_args,
438    });
439}
440
441sub xauth {
442    my ( $self, $username, $password ) = splice @_, 0, 3;
443    my %extra_args = @_ == 1 && is_ref($_[0]) ? %{ $_[0] } : @_;
444
445    $self->request(post => $self->oauth_url_for('access_token'), {
446        -accept     => 'application/x-www-form-urlencoded',
447        -oauth_args => {},
448        x_auth_mode     => 'client_auth',
449        x_auth_password => $password,
450        x_auth_username => $username,
451        %extra_args,
452    });
453}
454
4551;
456
457__END__
458
459=pod
460
461=encoding UTF-8
462
463=head1 NAME
464
465Twitter::API - A Twitter REST API library for Perl
466
467=for html <a href="https://travis-ci.org/semifor/Twitter-API"><img src="https://travis-ci.org/semifor/Twitter-API.svg?branch=master" alt="Build Status" /></a>
468
469=head1 VERSION
470
471version 1.0006
472
473=head1 SYNOPSIS
474
475    ### Common usage ###
476
477    use Twitter::API;
478    my $client = Twitter::API->new_with_traits(
479        traits              => 'Enchilada',
480        consumer_key        => $YOUR_CONSUMER_KEY,
481        consumer_secret     => $YOUR_CONSUMER_SECRET,
482        access_token        => $YOUR_ACCESS_TOKEN,
483        access_token_secret => $YOUR_ACCESS_TOKEN_SECRET,
484    );
485
486    my $me   = $client->verify_credentials;
487    my $user = $client->show_user('twitter');
488
489    # In list context, both the Twitter API result and a Twitter::API::Context
490    # object are returned.
491    my ($r, $context) = $client->home_timeline({ count => 200, trim_user => 1 });
492    my $remaning = $context->rate_limit_remaining;
493    my $until    = $context->rate_limit_reset;
494
495
496    ### No frills ###
497
498    my $client = Twitter::API->new(
499        consumer_key    => $YOUR_CONSUMER_KEY,
500        consumer_secret => $YOUR_CONSUMER_SECRET,
501    );
502
503    my $r = $client->get('account/verify_credentials', {
504        -token        => $an_access_token,
505        -token_secret => $an_access_token_secret,
506    });
507
508    ### Error handling ###
509
510    use Twitter::API::Util 'is_twitter_api_error';
511    use Try::Tiny;
512
513    try {
514        my $r = $client->verify_credentials;
515    }
516    catch {
517        die $_ unless is_twitter_api_error($_);
518
519        # The error object includes plenty of information
520        say $_->http_request->as_string;
521        say $_->http_response->as_string;
522        say 'No use retrying right away' if $_->is_permanent_error;
523        if ( $_->is_token_error ) {
524            say "There's something wrong with this token."
525        }
526        if ( $_->twitter_error_code == 326 ) {
527            say "Oops! Twitter thinks you're spam bot!";
528        }
529    };
530
531=head1 DESCRIPTION
532
533Twitter::API provides an interface to the Twitter REST API for perl.
534
535Features:
536
537=over 4
538
539=item *
540
541full support for all Twitter REST API endpoints
542
543=item *
544
545not dependent on a new distribution for new endpoint support
546
547=item *
548
549optionally specify access tokens per API call
550
551=item *
552
553error handling via an exception object that captures the full request/response context
554
555=item *
556
557full support for OAuth handshake and Xauth authentication
558
559=back
560
561Additional features are available via optional traits:
562
563=over 4
564
565=item *
566
567convenient methods for API endpoints with simplified argument handling via L<ApiMethods|Twitter::API::Trait::ApiMethods>
568
569=item *
570
571normalized booleans (Twitter likes 'true' and 'false', except when it doesn't) via L<NormalizeBooleans|Twitter::API::Trait::NormalizeBooleans>
572
573=item *
574
575automatic decoding of HTML entities via L<DecodeHtmlEntities|Twitter::API::Trait::DecodeHtmlEntities>
576
577=item *
578
579automatic retry on transient errors via L<RetryOnError|Twitter::API::Trait::RetryOnError>
580
581=item *
582
583"the whole enchilada" combines all the above traits via L<Enchilada|Twitter::API::Trait::Enchilada>
584
585=item *
586
587app-only (OAuth2) support via L<AppAuth|Twitter::API::Trait::AppAuth>
588
589=item *
590
591automatic rate limiting via L<RateLimiting|Twitter::API::Trait::RateLimiting>
592
593=back
594
595Some features are provided by separate distributions to avoid additional
596dependencies most users won't want or need:
597
598=over 4
599
600=item *
601
602async support via subclass L<Twitter::API::AnyEvent|https://github.com/semifor/Twitter-API-AnyEvent>
603
604=item *
605
606inflate API call results to objects via L<Twitter::API::Trait::InflateObjects|https://github.com/semifor/Twitter-API-Trait-InflateObjects>
607
608=back
609
610=head1 OVERVIEW
611
612=head2 Migration from Net::Twitter and Net::Twitter::Lite
613
614Migration support is included to assist users migrating from L<Net::Twitter>
615and L<Net::Twitter::Lite>. It will be removed from a future release. See
616L<Migration|Twitter::API::Trait::Migration> for details about migrating your
617existing Net::Twitter/::Lite applications.
618
619=head2 Normal usage
620
621Normally, you will construct a Twitter::API client with some traits, primarily
622B<ApiMethods>. It provides methods for each known Twitter API endpoint.
623Documentation is provided for each of those methods in
624L<ApiMethods|Twitter::API::Trait::ApiMethods>.
625
626See the list of traits in the L</DESCRIPTION> and refer to the documentation
627for each.
628
629=head2 Minimalist usage
630
631Without any traits, Twitter::API provides access to API endpoints with the
632L<get|get-url-args> and L<post|post-url-args> methods described below, as well
633as methods for managing OAuth authentication. API results are simply perl data
634structures decoded from the JSON responses. Refer to the L<Twitter API
635Documentation|https://dev.twitter.com/rest/public> for available endpoints,
636parameters, and responses.
637
638=head2 Twitter API V2 Beta Support
639
640Twitter intends to replace the current public API, version 1.1, with version 2.
641
642See L<https://developer.twitter.com/en/docs/twitter-api/early-access>.
643
644You can use Twitter::API for the V2 beta with the minimalist usage described
645just above by passing values in the constructor for C<api_version> and
646C<api_ext>.
647
648    my $client = Twitter::API->new_with_traits(
649        api_version => '2',
650        api_ext     => '',
651        %oauth_credentials,
652    );
653
654    my $user = $client->get("users/by/username/$username");
655
656More complete V2 support is anticipated in a future release.
657
658=head1 ATTRIBUTES
659
660=head2 consumer_key, consumer_secret
661
662Required. Every application has it's own application credentials.
663
664=head2 access_token, access_token_secret
665
666Optional. If provided, every API call will be authenticated with these user
667credentials. See L<AppAuth|Twitter::API::Trait::AppAuth> for app-only (OAuth2)
668support, which does not require user credentials. You can also pass options
669C<-token> and C<-token_secret> to specify user credentials on each API call.
670
671=head2 api_url
672
673Optional. Defaults to C<https://api.twitter.com>.
674
675=head2 upload_url
676
677Optional. Defaults to C<https://upload.twitter.com>.
678
679=head2 api_version
680
681Optional. Defaults to C<1.1>.
682
683=head2 api_ext
684
685Optional. Defaults to C<.json>.
686
687=head2 agent
688
689Optional. Used for both the User-Agent and X-Twitter-Client identifiers.
690Defaults to C<Twitter-API-$VERSION (Perl)>.
691
692=head2 timeout
693
694Optional. Request timeout in seconds. Defaults to C<10>.
695
696=head1 METHODS
697
698=head2 get($url, [ \%args ])
699
700Issues an HTTP GET request to Twitter. If C<$url> is just a path part, e.g.,
701C<account/verify_credentials>, it will be expanded to a full URL by prepending
702the C<api_url>, C<api_version> and appending C<.json>. A full URL can also be
703specified, e.g. C<https://api.twitter.com/1.1/account/verify_credentials.json>.
704
705This should accommodate any new API endpoints Twitter adds without requiring an
706update to this module.
707
708=head2 post($url, [ \%args ])
709
710See C<get> above, for a discussion C<$url>. For file upload, pass an array
711reference as described in
712L<https://metacpan.org/pod/distribution/HTTP-Message/lib/HTTP/Request/Common.pm#POST-url-Header-Value-...-Content-content>.
713
714=head2 oauth_request_token([ \%args ])
715
716This is the first step in the OAuth handshake. The only argument expected is
717C<callback>, which defaults to C<oob> for PIN based verification. Web
718applications will pass a callback URL.
719
720Returns a hashref that includes C<oauth_token> and C<oauth_token_secret>.
721
722See L<https://developer.twitter.com/en/docs/basics/authentication/api-reference/request_token>.
723
724=head2 oauth_authentication_url(\%args)
725
726This is the second step in the OAuth handshake. The only required argument is
727C<oauth_token>. Use the value returned by C<get_request_token>. Optional
728arguments: C<force_login> and C<screen_name> to pre-fill Twitter's
729authentication form.
730
731See L<https://developer.twitter.com/en/docs/basics/authentication/api-reference/authenticate>.
732
733=head2 oauth_authorization_url(\%args)
734
735Identical to C<oauth_authentication_url>, but uses authorization flow, rather
736than authentication flow.
737
738See L<https://developer.twitter.com/en/docs/basics/authentication/api-reference/authorize>.
739
740=head2 oauth_access_token(\%ags)
741
742This is the third and final step in the OAuth handshake. Pass the request
743C<token>, request C<token_secret> obtained in the C<get_request_token> call,
744and either the PIN number if you used C<oob> for the callback value in
745C<get_request_token> or the C<verifier> parameter returned in the web callback,
746as C<verfier>.
747
748See L<https://developer.twitter.com/en/docs/basics/authentication/api-reference/access_token>.
749
750=head2 xauth(\%args)
751
752Requires per application approval from Twitter. Pass C<username> and
753C<password>.
754
755=head1 SEE ALSO
756
757=over 4
758
759=item *
760
761L<API::Twitter> - Twitter.com API Client
762
763=item *
764
765L<AnyEvent::Twitter::Stream> - Receive Twitter streaming API in an event loop
766
767=item *
768
769L<AnyEvent::Twitter> - A thin wrapper for Twitter API using OAuth
770
771=item *
772
773L<Mojo::WebService::Twitter> - Simple Twitter API client
774
775=item *
776
777L<Net::Twitter> - Twitter::API's predecessor (also L<Net::Twitter::Lite>)
778
779=back
780
781=head1 AUTHOR
782
783Marc Mims <marc@questright.com>
784
785=head1 COPYRIGHT AND LICENSE
786
787This software is copyright (c) 2015-2021 by Marc Mims.
788
789This is free software; you can redistribute it and/or modify it under
790the same terms as the Perl 5 programming language system itself.
791
792=cut
793