File Coverage

blib/lib/WebService/RTMAgent.pm
Criterion Covered Total %
statement 107 107 100.0
branch 43 46 93.4
condition 5 9 55.5
subroutine 21 21 100.0
pod 11 11 100.0
total 187 194 96.3


line stmt bran cond sub pod time code
1 4     4   276909 use strict;
  4         46  
  4         119  
2 4     4   22 use warnings;
  4         14  
  4         291  
3             package WebService::RTMAgent 0.603;
4             # ABSTRACT: a user agent for the Remember The Milk API
5              
6             #pod =head1 SYNOPSIS
7             #pod
8             #pod $ua = WebService::RTMAgent->new;
9             #pod $ua->api_key($key_provided_by_rtm);
10             #pod $ua->api_secret($secret_provided_by_rtm);
11             #pod $ua->init;
12             #pod $url = $ua->get_auth_url; # then do something with the URL
13             #pod $res = $ua->tasks_getList('filter=status:incomplete');
14             #pod
15             #pod ...
16             #pod
17             #pod =head1 DESCRIPTION
18             #pod
19             #pod WebService::RTMAgent is a Perl implementation of the rememberthemilk.com API.
20             #pod
21             #pod =head2 Calling API methods
22             #pod
23             #pod All API methods documented at L
24             #pod can be called as methods, changing dots for underscores and optionnaly taking
25             #pod off the leading 'rtm': C<< $ua->auth_checkToken >>, C<< $ua->tasks_add >>, etc.
26             #pod
27             #pod Parameters should be given as a list of strings, e.g.:
28             #pod
29             #pod $ua->tasks_complete(
30             #pod "list_id=4231233",
31             #pod "taskseries_id=124233",
32             #pod "task_id=1234",
33             #pod );
34             #pod
35             #pod Refer to the API documentation for each method's parameters.
36             #pod
37             #pod Return values are the XML response, parsed through L. Please refer
38             #pod to XML::Simple for more information (and Data::Dumper, to see what the values
39             #pod look like) and the sample B script for examples.
40             #pod
41             #pod If the method call was not successful, C is returned, and an error
42             #pod message is set which can be accessed with the B method:
43             #pod
44             #pod $res = $ua->tasks_getList;
45             #pod die $ua->error unless defined $res;
46             #pod
47             #pod Please note that at this stage, I am not very sure that this is the best way to implement the API. "It works for me," but:
48             #pod
49             #pod =for :list
50             #pod * Parameters may turn to hashes at some point
51             #pod * Output values may turn to something more abstract and useful,
52             #pod as I gain experience with API usage.
53             #pod
54             #pod =head2 Authentication and authorisation
55             #pod
56             #pod Before using the API, you need to authenticate it. If you are going to be
57             #pod building a desktop application, you should get an API key and shared secret
58             #pod from the people at rememberthemilk.com (see
59             #pod L
60             #pod for rationale) and provide them to RTMAgent.pm with the C and
61             #pod C methods.
62             #pod
63             #pod You then need to proceed through the authentication cycle: create a useragent,
64             #pod call the get_auth_url method and direct a Web browser to the URL it returns.
65             #pod There RememberTheMilk will present you with an authorisation page: you can
66             #pod authorise the API to access your account.
67             #pod
68             #pod At that stage, the API will get a token which identifies the API/user
69             #pod authorisation. B saves the token in a file, so you should never need
70             #pod to do the authentication again.
71             #pod
72             #pod =head2 Proxy and other strange things
73             #pod
74             #pod The object returned by B is also a LWP::UserAgent. This means you can
75             #pod configure it the same way, in particular to cross proxy servers:
76             #pod
77             #pod $ua = new WebService::RTMAgent;
78             #pod $ua->api_key($key);
79             #pod $ua->api_secret($secret);
80             #pod $ua->proxy('http', 'https://proxy:8080');
81             #pod $ua->init;
82             #pod $list = $ua->tasks_getList;
83             #pod
84             #pod Incidentally, this is the reason why the C method exists: C needs
85             #pod to access the network, so its work cannot be done in C as that would leave
86             #pod no opportunity to configure the LWP::UserAgent.
87             #pod
88             #pod =cut
89              
90 4     4   34 use Carp;
  4         18  
  4         278  
91 4     4   24 use Digest::MD5 qw(md5_hex);
  4         18  
  4         271  
92 4     4   2969 use LWP::UserAgent;
  4         216636  
  4         155  
93 4     4   3406 use XML::Simple;
  4         37146  
  4         27  
94              
95 4     4   308 use parent 'LWP::UserAgent';
  4         9  
  4         24  
96              
97             my $REST_endpoint = "https://api.rememberthemilk.com/services/rest/";
98             my $auth_endpoint = "https://api.rememberthemilk.com/services/auth/";
99              
100             our $config_file = "$ENV{HOME}/.rtmagent";
101             our $config; # reference to config hash
102              
103             #pod =head1 PUBLIC METHODS
104             #pod
105             #pod =head2 $ua = WebService::RTMAgent->new;
106             #pod
107             #pod Creates a new agent.
108             #pod
109             #pod =cut
110              
111             sub new {
112 5     5 1 8916 my $class = shift;
113 5 50       23 Carp::confess("tried to call ->new on an instance") if ref $class;
114 5         72 my $self = $class->SUPER::new(@_);
115 5         12462 $self->verbose('');
116 5         26 return bless $self, $class;
117             }
118              
119             #pod =head2 $ua->api_key($key);
120             #pod
121             #pod =head2 $ua->api_secret($secret);
122             #pod
123             #pod Set the API key and secret. These are obtained from the people are
124             #pod RememberTheMilk.com.
125             #pod
126             #pod =head2 $ua->verbose('netin netout');
127             #pod
128             #pod Sets what type of traces the module should print. You can use 'netout' to print
129             #pod all the outgoing messages, 'netin' to print all the incoming messages.
130             #pod
131             #pod =head2 $err = $ua->error;
132             #pod
133             #pod Get a message describing the last error that happened.
134             #pod
135             #pod =cut
136              
137             # Create accessors
138             BEGIN {
139 4     4   769 my $subs;
140 4         13 foreach my $data ( qw/error verbose api_secret api_key/ ) {
141 16         51 $subs .= qq{
142             sub $data {
143             \$_[0]->{rtma_$data} =
144             defined \$_[1] ? \$_[1] : \$_[0]->{rtma_$data};
145             }
146             }
147             }
148 4 100   17 1 6492 eval $subs;
  17 100   18 1 204  
  18 100   6 1 254  
  6 100   32 1 365  
  32         686  
149             }
150              
151             #pod =head2 $ua->init;
152             #pod
153             #pod Performs authentication with RTM and various other book-keeping
154             #pod initialisations.
155             #pod
156             #pod =cut
157              
158             sub init {
159 5     5 1 1063 my ($self) = @_;
160              
161 5 100       81 if (-e $config_file) {
162 2 50 33     56 die "$config_file: can't read or write\n"
163             unless -r $config_file and -w $config_file;
164              
165 2         7 my $ok = eval {
166 2         22 $config = XMLin($config_file, KeyAttr=>'', ForceArray => ['undo']);
167 1         90060 1;
168             };
169 2 100       72134 croak "$config_file: Invalid XML file" unless $ok;
170             }
171              
172             # Check Token
173 4 100       17 if ($config->{token}) {
174 2         38 my $res = $self->auth_checkToken;
175 2 100       15 if (not defined $res) {
176 1         4 delete $config->{frob};
177 1         3 delete $config->{token};
178 1         22 croak $self->error;
179             }
180             }
181              
182             # If we have a frob and no token, we're half-way through
183             # authentication -- finish it
184 3 100 66     27 if ($config->{frob} and not $config->{token}) {
185 2         79 warn "frobbed -- getting token\n";
186 2         34 my $res = $self->auth_getToken("frob=$config->{frob}");
187 2 100       29 die $self->error."(Maybe you need to erase $config_file)\n"
188             unless defined $res;
189 1         5 $config->{token} = $res->{auth}->[0]->{token}->[0];
190 1         50 warn "token $config->{token}\n";
191             }
192              
193             # If we have no timeline, get one
194 2 100       23 unless ($config->{timeline}) {
195 1         15 my $res = $self->timelines_create();
196 1         5 $config->{timeline} = $res->{timeline}->[0];
197 1         11 $config->{undo} = [];
198             }
199             }
200              
201             #pod =head2 $ua->get_auth_url;
202             #pod
203             #pod Performs the beginning of the authentication: this returns a URL to which
204             #pod the user must then go to allow RTMAgent to access his or her account.
205             #pod
206             #pod This mecanism is slightly contrieved and designed so that users do not have
207             #pod to give their username and password to third party software (like this one).
208             #pod
209             #pod =cut
210              
211             sub get_auth_url {
212 1     1 1 365 my ($self) = @_;
213              
214 1         9 my $res = $self->auth_getFrob();
215              
216 1         4 my $frob = $res->{'frob'}->[0];
217              
218 1         2 my @params;
219 1         31 push @params, "api_key=".$self->api_key, "perms=delete", "frob=$frob";
220 1         4 push @params, "api_sig=".($self->sign(@params));
221              
222 1         7 my $url = "$auth_endpoint?". (join '&', @params);
223              
224             # save frob for later
225 1         3 $config->{'frob'} = $frob;
226              
227 1         7 return $url;
228             }
229              
230             #pod =head2 @undo = $ua->get_undoable;
231             #pod
232             #pod Returns the transactions which we know how to undo (unless data has been lost,
233             #pod that's all the undo-able transaction that go with the timeline that is saved in
234             #pod the state file).
235             #pod
236             #pod The value returned is a list of { id, op, [ params ] } with id the transaction
237             #pod id, op the API method that was called, and params the API parameters that were
238             #pod called.
239             #pod
240             #pod =cut
241              
242             sub get_undoable {
243 2     2 1 6 my ($self) = @_;
244              
245 2         9 return $config->{undo};
246             }
247              
248             #pod =head2 $ua->clear_undo(3);
249             #pod
250             #pod Removes an undo entry.
251             #pod
252             #pod =cut
253              
254             sub clear_undo {
255 1     1 1 300 my ($self, $index) = @_;
256              
257 1         2 splice @{$config->{undo}}, $index, 1;
  1         8  
258             }
259              
260             #pod =head1 PRIVATE METHODS
261             #pod
262             #pod Don't use those and we'll stay friends.
263             #pod
264             #pod =head2 $ua->sign(@params);
265             #pod
266             #pod Returns the md5 signature for signing parameters. See RTM Web site for details.
267             #pod This should only be useful for the module, don't use it.
268             #pod
269             #pod =cut
270              
271             sub sign {
272 15     15 1 56 my ($self, @params) = @_;
273              
274 15         82 my $sign_str = join '', sort @params;
275 15         80 $sign_str =~ s/=//g;
276              
277 15         342 return md5_hex($self->api_secret."$sign_str");
278             }
279              
280             #pod =head2 $ua->rtm_request("rtm.tasks.getList", "list_id=234", "taskseries_id=2"..)
281             #pod
282             #pod Signs the parameters, performs the request, returns a parsed XML::Simple
283             #pod object.
284             #pod
285             #pod =cut
286              
287             sub rtm_request {
288 13     13 1 36 my ($self, $request, @params) = @_;
289              
290 13         44 unshift @params, "method=$request";
291 13         355 push @params, "api_key=".$self->api_key;
292 13 100       58 push @params, "auth_token=$config->{token}" if exists $config->{token};
293 13 100       40 push @params, "timeline=$config->{timeline}" if exists $config->{timeline};
294 13         47 my $sig = $self->sign(@params);
295 13         48 my $param = join '&', @params;
296              
297 13         93 my $req = HTTP::Request->new( POST => $REST_endpoint);
298 13         25994 $req->content_type('application/x-www-form-urlencoded');
299 13         535 $req->content("$param&api_sig=$sig");
300 13 100       658 warn("request:\n".$req->as_string."\n\n") if $self->verbose =~ /netout/;
301              
302 13         206 my $res = $self->request($req);
303 13 100       8884 die $res->status_line unless $res->is_success;
304              
305 12 100       432 warn("response:\n".$res->as_string."\n\n") if $self->verbose =~ /netin/;
306 12         339 return XMLin($res->content, KeyAttr=>'', ForceArray=>1);
307             }
308              
309             # AUTOLOAD gets calls to undefined functions
310             # we add 'rtm' and change underscores to dots, to change perl function
311             # names to RTM API: tasks_getList => rtm.tasks.getList
312             # arguments are as strings:
313             # $useragent->tasks_complete("list_id=$a", "taskseries_id=$b" ...);
314             our $AUTOLOAD;
315             sub AUTOLOAD {
316 13     13   2186 my $function = $AUTOLOAD;
317              
318 13         26 my $self = shift;
319              
320 13         82 $function =~ s/^.*:://; # Remove class name
321 13         52 $function =~ s/_/./g; # Change underscores to dots (auth_getFrob => auth.getFrob)
322 13 100       85 $function =~ s/^/rtm./ unless $function =~ /^rtm./; # prepends rtm if needed
323 13         54 my $res = $self->rtm_request($function, @_);
324              
325             # Treat errors
326 12 100       250823 if (exists $res->{'err'}) {
327 4 100       247 croak ("$function does not exist\n") if $res->{'err'}->[0]->{'code'} == 112;
328 3         116 $self->error("$res->{'err'}->[0]->{'code'}: $res->{'err'}->[0]->{'msg'}\n");
329 3         21 return undef;
330             }
331              
332             # If action is undo-able, store transaction ID
333 8 50 66     43 if (exists $res->{transaction} and
334             exists $res->{transaction}->[0]->{undoable}) {
335 2         14 push @{$config->{undo}}, {
336             'id' => $res->{transaction}->[0]->{id},
337 2         4 'op' => $function,
338             'params' => \@_,
339             };
340             }
341 8         34 return $res;
342             }
343              
344              
345             # When destroying the object, save the config file
346             # (careful, this all means we can only have one instance running...)
347             sub DESTROY {
348 5 100   5   2513 return unless defined $config;
349 3         360 open my $f, "> $config_file";
350 3         43 print $f XMLout($config, NoAttr=>1, RootName=>'RTMAgent');
351             }
352              
353             #pod =head1 FILES
354             #pod
355             #pod =for :list
356             #pod = F<~/.rtmagent>
357             #pod XML file containing runtime data: frob, timeline, authentication token. This
358             #pod file is overwritten on exit, which means you should only have one instance of
359             #pod RTMAgent (this should be corrected in a future version).
360             #pod
361             #pod =head1 SEE ALSO
362             #pod
363             #pod =for :list
364             #pod * C<< L >>, example command-line script.
365             #pod * L
366             #pod * L
367             #pod
368             #pod =cut
369              
370             1;
371              
372             __END__