File Coverage

blib/lib/Hypothesis/API.pm
Criterion Covered Total %
statement 41 213 19.2
branch 0 82 0.0
condition 0 12 0.0
subroutine 14 23 60.8
pod 6 6 100.0
total 61 336 18.1


line stmt bran cond sub pod time code
1             package Hypothesis::API;
2              
3 1     1   23436 use 5.006;
  1         4  
4 1     1   6 use strict;
  1         1  
  1         32  
5 1     1   5 use warnings;
  1         6  
  1         47  
6              
7 1     1   727 use namespace::autoclean;
  1         23297  
  1         5  
8 1     1   807 use Moose;
  1         433417  
  1         6  
9 1     1   5805 use Storable qw( dclone );
  1         2587  
  1         64  
10 1     1   5 use Try::Tiny;
  1         2  
  1         44  
11              
12 1     1   1711 use CGI::Cookie;
  1         7670  
  1         371  
13 1     1   1759 use HTTP::Cookies;
  1         15341  
  1         41  
14 1     1   534 use HTTP::Request;
  1         26165  
  1         64  
15 1     1   1131 use JSON;
  1         15233  
  1         8  
16 1     1   1094 use LWP::UserAgent;
  1         22800  
  1         62  
17 1     1   368 use URI;
  1         4  
  1         47  
18 1     1   930 use URI::Encode;
  1         13184  
  1         2033  
19              
20             # For better performance, also install:
21             # JSON::XS
22              
23             # DEBUG
24             # use Data::Dumper;
25             #
26             # 0 = None, 5 = Max:
27             my $VERB = 0;
28              
29             =pod
30              
31             =head1 NAME
32              
33             Hypothesis::API - Wrapper for the hypothes.is web (HTTP) API.
34              
35             =head1 VERSION
36              
37             Version 0.11
38              
39             =cut
40              
41             our $VERSION = '0.11';
42              
43             =head1 SYNOPSIS
44              
45             A Perl wrapper and utility functions for the hypothes.is web (HTTP) API.
46              
47             Create a hypothes.is object.
48              
49             use Hypothesis::API;
50              
51             my $H = Hypothesis::API->new();
52              
53             # or if user-specific actions without login are needed (no known uses yet):
54             my $H = Hypothesis::API->new($username);
55              
56             # or if login is needed (usually for annotator-store alterations)
57             my $H = Hypothesis::API->new($username, $password);
58              
59              
60             Login-required functionality:
61              
62             $H->login;
63              
64             my $payload = {
65             "uri" => 'http://my.favorite.edu/doc.html',
66             "text" => "testing create in hypothes.is API"
67             };
68             my $id = $H->create($payload);
69             $H->delete_id($id);
70              
71             Search functionality (no login needed):
72              
73             my $annotation = $H->read_id($id);
74             die if ($annotation->{'id'} ne $id);
75              
76             my $page_size = 20;
77             my $iter = $H->search({limit => 100}, $page_size);
78             my @annotations;
79             while ( my $item = $iter->() ) {
80             push @annotations, $item;
81             }
82              
83             =head1 EXPORT
84              
85             Currently nothing.
86              
87             =cut
88              
89             my $json = JSON->new->allow_nonref;
90             $json->pretty(1);
91             $json->canonical(1);
92              
93             has 'api_url' => (
94             is => 'ro',
95             default => 'https://hypothes.is/api',
96             predicate => 'has_api_url',
97             );
98              
99             has 'app_url' => (
100             is => 'ro',
101             default => 'https://hypothes.is/app',
102             predicate => 'has_app_url',
103             );
104              
105             has 'username' => (
106             is => 'ro',
107             predicate => 'has_username',
108             );
109              
110             has 'password' => (
111             is => 'ro',
112             predicate => 'has_password',
113             );
114              
115             has 'token' => (
116             is => 'ro',
117             predicate => 'has_token',
118             writer => '_set_token',
119             init_arg => undef,
120             );
121              
122             has 'csrf_token' => (
123             is => 'ro',
124             predicate => 'has_csrf_token',
125             writer => '_set_csrf_token',
126             init_arg => undef,
127             );
128              
129             has 'ua' => (
130             is => 'ro',
131             default => sub { LWP::UserAgent->new; },
132             predicate => 'has_ua',
133             );
134              
135             has 'uri_encoder' => (
136             is => 'ro',
137             default => sub {
138             URI::Encode->new( {
139             encode_reserved => 0,
140             double_encode => 0,
141             } );
142             },
143             predicate => 'has_uri_encoder',
144             );
145              
146             around BUILDARGS => sub {
147             my $orig = shift;
148             my $class = shift;
149              
150             if ( @_ >= 2 ) {
151             if ( @_ > 2) {
152             warn "At most two arguments expected in constructor.\n";
153             }
154             return $class->$orig( username => $_[0], password => $_[1] );
155             } elsif ( @_ == 1 && !ref $_[0] ) {
156             return $class->$orig( username => $_[0], password => undef );
157             } else {
158             return $class->$orig( username => undef, password => undef );
159             }
160             };
161              
162             =head1 SUBROUTINES/METHODS
163              
164             =head2 create(\%payload)
165              
166             Generalized interface to POST /api/annotations
167              
168             In the simplest form, creates an annotation
169             $payload->{'text'} at $payload->{'uri'}.
170             For more sophisticated usage please see the
171             hypothes.is API documentation.
172              
173             Returns annotation id if created or HTTP status
174             code otherwise.
175              
176             =cut
177              
178             sub create {
179 0     0 1   my ($self, $payload) = @_;
180              
181 0 0         if (ref($payload) ne "HASH") {
182 0           warn 'Payload is not a hashref.\n';
183 0           return -1;
184             }
185 0 0         if (not exists $payload->{'uri'}) {
186 0           warn "Payload does not contain a 'uri' key to be annotated.\n";
187 0           return -1;
188             }
189 0           my $payload_out = dclone $payload;
190 0           my $user = $self->username;
191 0           my $user_acct = "acct:$user\@hypothes.is";
192 0           $payload_out->{'user'} = $user_acct;
193 0 0         if (not exists $payload->{'permissions'}) {
194 0           $payload_out->{'permissions'} = {
195             "read" => ["group:__world__"],
196             "update" => [$user_acct],
197             "delete" => [$user_acct],
198             "admin" => [$user_acct]
199             };
200             }
201 0 0         if (not exists $payload->{'document'}) {
202 0           $payload_out->{'document'} = {};
203             }
204 0 0         if (not exists $payload->{'text'}) {
205 0           $payload_out->{'text'} = undef;
206             }
207 0 0         if (not exists $payload->{'tags'}) {
208 0           $payload_out->{'tags'} = undef;
209             }
210 0 0         if (not exists $payload->{'target'}) {
211 0           $payload_out->{'target'} = undef;
212             }
213            
214 0           my $data = $json->encode($payload_out);
215 0           my $h = HTTP::Headers->new;
216 0           $h->header(
217             'content-type' => 'application/json;charset=UTF-8',
218             'x-csrf-token' => $self->csrf_token,
219             'X-Annotator-Auth-Token' => $self->token,
220             );
221 0           $self->ua->default_headers( $h );
222 0           my $url = URI->new( "${\$self->api_url}/annotations" );
  0            
223 0           my $response = $self->ua->post( $url, Content => $data );
224 0 0         if ($response->code == 200) {
225 0           my $json_content = $json->decode($response->content);
226 0 0         if (exists $json_content->{'id'}) {
227 0           return $json_content->{'id'};
228             } else {
229 0           return -1;
230             }
231             } else {
232 0           return $response->code;
233             }
234             }
235              
236              
237             =head2 delete_id($id)
238              
239             Interface to DELETE /api/annotations/<id>
240              
241             Given an annotation id, returns a boolean value indicating whether or
242             not the annotation for that id has been successfully delete (1 = yes,
243             0 = no).
244              
245             =cut
246              
247             sub delete_id {
248 0     0 1   my ($self, $id) = @_;
249 0 0         if (not defined $id) {
250 0           warn "No id given to delete.\n";
251 0           return 0;
252             }
253 0           my $h = HTTP::Headers->new;
254 0           $h->header(
255             'content-type' => 'application/json;charset=UTF-8',
256             'x-csrf-token' => $self->csrf_token,
257             'X-Annotator-Auth-Token' => $self->token,
258             );
259 0           $self->ua->default_headers( $h );
260 0           my $url = URI->new( "${\$self->api_url}/annotations/$id" );
  0            
261 0           my $response = $self->ua->delete( $url );
262 0           my $json_content;
263             my $success = try{
264 0     0     $json_content = $json->decode($response->content);
265             } catch {
266 0     0     warn "Trouble decoding JSON: $_\n";
267 0           warn $response->content;
268 0           return 0;
269 0           };
270 0 0         if (not $success) {
271 0           return 0;
272             }
273 0           my $content_type = ref($json_content);
274 0 0         if ($content_type eq "HASH") {
275 0 0         if (defined $json_content->{'deleted'}) {
276 0 0         if ($json_content->{'deleted'}) {
    0          
277 0           return 1;
278             } elsif (not $json_content->{'deleted'}) {
279 0           return 0;
280             } else { # Never reached in current implementation
281 0           warn "unexpected deletion status: ${\$json_content->{'deleted'}}";
  0            
282 0           return 0;
283             }
284             } else {
285 0           die "Received unexpected object: no 'deleted' entry present.";
286             }
287             } else {
288 0           die "Got $content_type; expected an ARRAY or HASH.";
289             }
290             }
291              
292              
293             =head2 login
294              
295             Proceeds to login; on success retrieves and stores
296             CSRF and bearer tokens.
297              
298             =cut
299              
300             sub login {
301 0     0 1   my ($self) = @_;
302              
303             # Grab cookie_jar for csrf_token, etc.
304 0           my $request = HTTP::Request->new(GET => $self->app_url);
305 0           my $cookie_jar = HTTP::Cookies->new();
306 0           $self->ua->cookie_jar($cookie_jar);
307 0           my $response = $self->ua->request($request);
308 0           $cookie_jar->extract_cookies( $response );
309 0           my %cookies = CGI::Cookie->parse($cookie_jar->as_string);
310 0 0         if (exists $cookies{'Set-Cookie3: XSRF-TOKEN'}) {
311 0           $self->_set_csrf_token($cookies{'Set-Cookie3: XSRF-TOKEN'}->value);
312             } else {
313 0           warn "Login failed: couldn't obtain CSRF token.";
314 0           return -1;
315             }
316              
317 0           my $h = HTTP::Headers->new;
318 0           $h->header(
319             'content-type' => 'application/json;charset=UTF-8',
320             'x-csrf-token' => $self->csrf_token,
321             );
322 0           $self->ua->default_headers( $h );
323 0           my $payload = {
324             username => $self->username,
325             password => $self->password
326             };
327 0           my $data = $json->encode($payload);
328 0           $response = $self->ua->post(
329             $self->app_url . '?__formid__=login',
330             Content => $data
331             );
332 0           my $url = URI->new( "${\$self->api_url}/token" );
  0            
333 0           $url->query_form(assertion => $self->csrf_token);
334 0           $response = $self->ua->get( $url );
335 0           $self->_set_token($response->content);
336              
337 0           return 0;
338             }
339              
340              
341             =head2 read_id($id)
342              
343             Interface to GET /api/annotations/<id>
344              
345             Returns the annotation for a given annotation id if id is defined or
346             nonempty. Otherwise (in an effort to remain well-typed) returns the
347             first annotation on the list returned from hypothes.is. At the time of
348             this writing, this functionality of empty 'search' and 'read' requests
349             are identical in the HTTP API, but in this Perl API, 'read'
350             returns a scalar value and 'search' returns an array.
351              
352             =cut
353              
354             sub read_id {
355 0     0 1   my ($self, $id) = @_;
356 0 0         if (not defined $id) {
357 0           $id = q();
358             }
359 0           my $url = URI->new( "${\$self->api_url}/annotations/$id" );
  0            
360 0           my $response = $self->ua->get( $url );
361 0           my $json_content = $json->decode($response->content);
362 0           my $content_type = ref($json_content);
363 0 0         if ($content_type eq "HASH") {
364 0 0         if (defined $json_content->{'id'}) {
    0          
365 0           return $json_content;
366             } elsif (defined $json_content->{'rows'}) {
367 0           return $json_content->{'rows'}->[0];
368             } else {
369 0           die "Don't know how to find the annotation.";
370             }
371             } else {
372 0           die "Got $content_type; expected a HASH.";
373             }
374             }
375              
376              
377              
378             =head2 search(\%query, $page_size)
379              
380             Generalized interface to GET /api/search
381              
382             Generalized query function.
383              
384             query is a hash ref with the following optional keys
385             as defined in the hypothes.is HTTP API:
386             * limit
387             * offset
388             * uri
389             * uri.parts
390             * text
391             * quote
392             * user
393              
394             page_size is an additional parameter related to $query->limit
395             and $query->offset, which specifies the number of annotations
396             to fetch at a time, but does not override the spirit of either
397             of the $query parameters.
398              
399             Tries not to return annotations created after initiation
400             of the search.
401              
402             Note that while this function has been made robust to addition of
403             new annotations being created during a query, it is not yet
404             robust to deletion of annotations.
405              
406             =cut
407              
408             # FIXME: improve handling of deletions
409              
410             sub search {
411 0     0 1   my ($self, $query, $page_size) = @_;
412              
413 0           my $h = HTTP::Headers->new;
414 0           $h->header(
415             'content-type' => 'application/json;charset=UTF-8',
416             'x-csrf-token' => $self->csrf_token,
417             );
418 0 0         if (not defined $query) {
419 0           $query = {};
420             }
421 0 0         if (not defined $query->{ 'limit' }) {
422             #Default at the time, but need to make explicit here:
423 0           $query->{ 'limit' } = 20;
424             }
425 0 0         if (not defined $page_size) {
426             #Default at the time, but need to make explicit here:
427 0           $page_size = 20;
428             }
429 0 0         if ( defined $query->{ 'uri' } ) {
430             $query->{ 'uri' } = $self->uri_encoder->encode(
431 0           $query->{ 'uri' }
432             );
433             }
434              
435 0           my $done = 0;
436 0           my $next_buf_start;
437 0           my $num_returned = 0;
438 0           my $limit_orig = $query->{ 'limit' };
439 0           $query->{ 'limit' } = $page_size + 1;
440              
441 0           my @annotation_buff = ();
442             return sub {
443 0 0 0 0     $done = 1 if (defined $limit_orig and $num_returned >= $limit_orig);
444 0 0 0       QUERY: if (@annotation_buff == 0 && not $done) {
445 0 0         warn "fetching annotations from server.\n" if $VERB > 0;
446             #Need to refill response buffer
447 0           my $url = URI->new( "${\$self->api_url}/search" );
  0            
448 0           $url->query_form($query);
449 0 0         warn $url, "\n" if $VERB > 1;
450 0           my $response;
451             my $json_content;
452 0           $response = $self->ua->get( $url );
453 0           $json_content = $json->decode($response->content);
454 0           @annotation_buff = @{$json_content->{ 'rows' }};
  0            
455 0 0         if ($limit_orig eq 'Infinity') {
456             # OK, we get the point, but let's get finite.
457 0           $limit_orig = $json_content->{ 'total' };
458 0           $query->{ 'limit' } = $json_content->{ 'total' };
459             }
460 0 0         if (defined $next_buf_start) {
461             # This assumes that the feed is like a stack: LIFO.
462             # Annotations created after the search call
463             # shouldn't be returned.
464 0   0       while (@annotation_buff && $next_buf_start->{'id'} ne $annotation_buff[0]->{'id'}) {
465 0 0         warn "mismatch: scanning for last seen id\n" if $VERB > 0;
466 0           shift @annotation_buff;
467 0 0         if (@annotation_buff == 0) {
468 0           $query->{ 'offset' } += $page_size;
469 0           goto QUERY;
470             }
471             }
472             }
473 0           $next_buf_start = pop @annotation_buff;
474 0 0         $done = 1 if (@annotation_buff == 0);
475 0           $query->{ 'offset' } += $page_size;
476 0 0         warn $response->content if $VERB > 5;
477             # Handle edge case that look-ahead element is the last element:
478 0 0 0       if (($num_returned + 1) == $limit_orig
479             || $json_content->{ 'total' } == 1 ) {
480 0           $num_returned++;
481 0           return $next_buf_start;
482             }
483             }
484 0           $num_returned++;
485 0 0         return undef if $done;
486 0           return shift @annotation_buff;
487             }
488              
489 0           }
490              
491              
492             =head2 update_id($id, \%payload)
493              
494             Interface to PUT /api/annotations/<id>
495              
496             Updates the annotation for a given annotation id if id is defined and
497             the user is authenticated and has update permissions. Takes a payload
498             as described for 'search'. Only fields specified in the new payload
499             are altered; other existing fields should remain unchanged.
500              
501             Returns a boolean value indicating whether or not the annotation for
502             that id has been successfully delete (1 = yes, 0 = no).
503              
504             =cut
505              
506             sub update_id {
507 0     0 1   my ($self, $id, $payload) = @_;
508 0 0         if (not defined $id) {
509 0           die "Can only call update if given an id.";
510             }
511 0           my $data = $json->encode($payload);
512 0           my $h = HTTP::Headers->new;
513 0           $h->header(
514             'content-type' => 'application/json;charset=UTF-8',
515             'x-csrf-token' => $self->csrf_token,
516             'X-Annotator-Auth-Token' => $self->token,
517             );
518 0           $self->ua->default_headers( $h );
519 0           my $url = URI->new( "${\$self->api_url}/annotations/$id" );
  0            
520 0           my $response = $self->ua->put( $url, Content => $data );
521 0           my $json_content = $json->decode($response->content);
522 0           my $content_type = ref($json_content);
523 0 0         if ($content_type eq "HASH") {
524 0 0         if (defined $json_content->{'updated'}) {
525 0 0         if ($json_content->{'updated'}) {
    0          
526 0           return 1;
527             } elsif (not $json_content->{'deleted'}) {
528 0           return 0;
529             } else { # Never reached in current implementation
530 0           warn "unexpected update status: ${\$json_content->{'updated'}}";
  0            
531 0           return 0;
532             }
533             } else {
534 0           die "Received unexpected object: no 'updated' entry present.";
535             }
536             } else {
537 0           die "Got $content_type; expected an ARRAY or HASH.";
538             }
539             }
540              
541             =head1 AUTHOR
542              
543             Brandon E. Barker, C<< <brandon.barker at cornell.edu> >>
544              
545             Created 06/2015
546              
547             Licensed under the Apache License, Version 2.0 (the "Apache License");
548             also licensed under the Artistic License 2.0 (the "Artistic License").
549             you may not use this file except in compliance with one of
550             these two licenses. You may obtain a copy of the Apache License at
551              
552             http://www.apache.org/licenses/LICENSE-2.0
553              
554             Alternatively a copy of the Apache License should be available in the
555             LICENSE-2.0.txt file found in this source code repository.
556              
557             You may obtain a copy of the Artistic License at
558              
559             http://www.perlfoundation.org/artistic_license_2_0
560              
561             Alternatively a copy of the Artistic License should be available in the
562             artistic-2_0.txt file found in this source code repository.
563              
564             Unless required by applicable law or agreed to in writing, software
565             distributed under the License is distributed on an "AS IS" BASIS,
566             WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
567             See the Apache License or Artistic License for the specific language
568             governing permissions and limitations under the licenses.
569              
570             =head1 BUGS
571              
572             Please report any bugs or feature requests at L<https://github.com/bbarker/Hypothesis-API/issues>.
573             Alternatively, you may send them to C<bug-hypothesis-api at rt.cpan.org>, or through
574             the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Hypothesis-API>, but this
575             is not preferred. In either case, I will be notified, and then you'll
576             automatically be notified of progress on your bug as I make changes.
577              
578             =head1 REPOSITORY
579              
580             L<https://github.com/bbarker/Hypothesis-API>
581              
582              
583             =head1 SUPPORT
584              
585             You can find documentation for this module with the perldoc command.
586              
587             perldoc Hypothesis::API
588              
589             You can also look for information at:
590              
591             =over 4
592              
593             =item * RT: CPAN's request tracker (report bugs here)
594              
595             L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Hypothesis-API>
596              
597             =item * AnnoCPAN: Annotated CPAN documentation
598              
599             L<http://annocpan.org/dist/Hypothesis-API>
600              
601             =item * CPAN Ratings
602              
603             L<http://cpanratings.perl.org/d/Hypothesis-API>
604              
605             =item * Search CPAN
606              
607             L<http://search.cpan.org/dist/Hypothesis-API/>
608              
609             =back
610              
611              
612             =head1 ACKNOWLEDGEMENTS
613              
614             We are thankful for support from the Alfred P. Sloan Foundation.
615              
616             =cut
617              
618             1; # End of Hypothesis::API