File Coverage

blib/lib/Hypothesis/API.pm
Criterion Covered Total %
statement 41 238 17.2
branch 0 94 0.0
condition 0 18 0.0
subroutine 14 24 58.3
pod 7 7 100.0
total 62 381 16.2


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