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