File Coverage

blib/lib/Hypothesis/API.pm
Criterion Covered Total %
statement 41 275 14.9
branch 0 114 0.0
condition 0 18 0.0
subroutine 14 27 51.8
pod 9 10 90.0
total 64 444 14.4


line stmt bran cond sub pod time code
1             package Hypothesis::API;
2              
3 1     1   22146 use 5.006;
  1         5  
4 1     1   6 use strict;
  1         3  
  1         27  
5 1     1   6 use warnings;
  1         7  
  1         46  
6              
7 1     1   891 use namespace::autoclean;
  1         21418  
  1         4  
8 1     1   988 use Moose;
  1         483421  
  1         8  
9 1     1   8372 use Storable qw( dclone );
  1         3689  
  1         92  
10 1     1   8 use Try::Tiny;
  1         2  
  1         80  
11              
12 1     1   9482 use CGI::Cookie;
  1         17866  
  1         54  
13 1     1   4179 use HTTP::Cookies;
  1         20445  
  1         34  
14 1     1   672 use HTTP::Request;
  1         27117  
  1         34  
15 1     1   1101 use JSON;
  1         13398  
  1         7  
16 1     1   1215 use LWP::UserAgent;
  1         19207  
  1         36  
17 1     1   9 use URI;
  1         2  
  1         24  
18 1     1   821 use URI::Encode;
  1         14006  
  1         3390  
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.13
38              
39             =cut
40              
41             our $VERSION = '0.13';
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 = try_json_decode($response);
235 0 0         if (not $json_content) {
236 0           die "Was unable to decode JSON content for id from 'create' call.";
237             }
238 0 0         if (exists $json_content->{'id'}) {
239 0           return $json_content->{'id'};
240             } else {
241 0           return -1;
242             }
243             } else {
244 0           return $response->code;
245             }
246             }
247              
248              
249             =head2 delete_id($id)
250              
251             Interface to DELETE /api/annotations/<id>
252              
253             Given an annotation id, returns a boolean value indicating whether or
254             not the annotation for that id has been successfully delete (1 = yes,
255             0 = no).
256              
257             =cut
258              
259             sub delete_id {
260 0     0 1   my ($self, $id) = @_;
261 0 0         if (not defined $id) {
262 0           warn "No id given to delete.\n";
263 0           return 0;
264             }
265 0           my $h = HTTP::Headers->new;
266 0           $h->header(
267             'content-type' => 'application/json;charset=UTF-8',
268             'x-csrf-token' => $self->csrf_token,
269             'X-Annotator-Auth-Token' => $self->token,
270             );
271 0           $self->ua->default_headers( $h );
272 0           my $url = URI->new( "${\$self->api_url}/annotations/$id" );
  0            
273 0           my $response = $self->ua->delete( $url );
274 0           my $json_content = 0;
275 0 0         if ($response->code != 500) {
276 0           $json_content = try_json_decode($response);
277 0 0         if (not $json_content) {
278 0           die "Was unable to decode JSON content for delete_id, id: $id";
279             }
280             } else {
281 0           die "Received status code ${\$response->code} from Hypothes.is in delete_id.";
  0            
282             }
283 0           my $content_type = ref($json_content);
284 0 0         if ($content_type eq "HASH") {
285 0 0         if (defined $json_content->{'deleted'}) {
286 0 0         if ($json_content->{'deleted'}) {
    0          
287 0           return 1;
288             } elsif (not $json_content->{'deleted'}) {
289 0           return 0;
290             } else { # Never reached in current implementation
291 0           warn "unexpected deletion status: ${\$json_content->{'deleted'}}";
  0            
292 0           return 0;
293             }
294             } else {
295 0           warn "Received unexpected object: no 'deleted' entry present.";
296 0           return 0;
297             }
298             } else {
299 0           die "Got $content_type; expected an ARRAY or HASH.";
300             }
301             }
302              
303              
304             =head2 login
305              
306             Proceeds to login; on success retrieves and stores
307             CSRF and bearer tokens.
308              
309             =cut
310              
311             sub login {
312 0     0 1   my ($self) = @_;
313              
314             # Grab cookie_jar for csrf_token, etc.
315 0           my $request = HTTP::Request->new(GET => $self->app_url);
316 0           my $cookie_jar = HTTP::Cookies->new();
317 0           $self->ua->cookie_jar($cookie_jar);
318 0           my $response = $self->ua->request($request);
319 0           $cookie_jar->extract_cookies( $response );
320 0           my %cookies = CGI::Cookie->parse($cookie_jar->as_string);
321 0 0         if (exists $cookies{'Set-Cookie3: XSRF-TOKEN'}) {
322 0           $self->_set_csrf_token($cookies{'Set-Cookie3: XSRF-TOKEN'}->value);
323             } else {
324 0           warn "Login failed: couldn't obtain CSRF token.";
325 0           return -1;
326             }
327              
328 0           my $h = HTTP::Headers->new;
329 0           $h->header(
330             'content-type' => 'application/json;charset=UTF-8',
331             'x-csrf-token' => $self->csrf_token,
332             );
333 0           $self->ua->default_headers( $h );
334 0           my $payload = {
335             username => $self->username,
336             password => $self->password
337             };
338 0           my $data = $json->encode($payload);
339 0           $response = $self->ua->post(
340             $self->app_url . '?__formid__=login',
341             Content => $data
342             );
343 0           my $url = URI->new( "${\$self->api_url}/token" );
  0            
344 0           $url->query_form(assertion => $self->csrf_token);
345 0           $response = $self->ua->get( $url );
346 0           $self->_set_token($response->content);
347              
348 0           return 0;
349             }
350              
351              
352             =head2 read_id($id)
353              
354             Interface to GET /api/annotations/<id>
355              
356             Returns the annotation for a given annotation id if id is defined or
357             nonempty. Otherwise (in an effort to remain well-typed) returns the
358             first annotation on the list returned from hypothes.is. At the time of
359             this writing, this functionality of empty 'search' and 'read' requests
360             are identical in the HTTP API, but in this Perl API, 'read'
361             returns a scalar value and 'search' returns an array.
362              
363             =cut
364              
365             sub read_id {
366 0     0 1   my ($self, $id) = @_;
367 0 0         if (not defined $id) {
368 0           $id = q();
369             }
370 0           my $url = URI->new( "${\$self->api_url}/annotations/$id" );
  0            
371 0           my $response = $self->ua->get( $url );
372 0           my $json_content = 0;
373 0 0         if ($response->code != 500) {
374 0           $json_content = try_json_decode($response);
375 0 0         if (not $json_content) {
376 0           die "Was unable to decode JSON content for read_id, id: $id"
377             }
378             } else {
379 0           die "Received status code ${\$response->code} from Hypothes.is in read_id.";
  0            
380             }
381 0           my $content_type = ref($json_content);
382 0 0         if ($content_type eq "HASH") {
383 0 0         if (defined $json_content->{'id'}) {
    0          
384 0           return $json_content;
385             } elsif (defined $json_content->{'rows'}) {
386 0           return $json_content->{'rows'}->[0];
387             } else {
388 0           die "Don't know how to find the annotation.";
389             }
390             } else {
391 0           die "Got $content_type; expected a HASH.";
392             }
393             }
394              
395              
396              
397             =head2 search(\%query, $page_size)
398              
399             Generalized interface to GET /api/search
400              
401             Generalized query function.
402              
403             query is a hash ref with the following optional keys
404             as defined in the hypothes.is HTTP API:
405             * limit
406             * offset
407             * uri
408             * uri.parts
409             * text
410             * quote
411             * user
412              
413             page_size is an additional parameter related to $query->limit
414             and $query->offset, which specifies the number of annotations
415             to fetch at a time, but does not override the spirit of either
416             of the $query parameters.
417              
418             Tries not to return annotations created after initiation
419             of the search.
420              
421             Note that while this function has been made robust to addition of
422             new annotations being created during a query, it is not yet
423             robust to deletion of annotations.
424              
425             =cut
426              
427             # FIXME: improve handling of deletions
428              
429             sub search {
430 0     0 1   my ($self, $query, $page_size) = @_;
431              
432 0           my $h = HTTP::Headers->new;
433 0           $h->header(
434             'content-type' => 'application/json;charset=UTF-8',
435             'x-csrf-token' => $self->csrf_token,
436             );
437 0 0         if (not defined $query) {
438 0           $query = {};
439             }
440 0 0         if ( defined $query->{ 'uri' } ) {
441             $query->{ 'uri' } = $self->uri_encoder->encode(
442 0           $query->{ 'uri' }
443             );
444             }
445 0 0         if (not defined $page_size) {
446             #Default at the time, but need to make explicit here:
447 0           $page_size = $page_size_default;
448             }
449 0 0         if ( not defined $query->{ 'limit' } ) {
450             #Default at the time, but need to make explicit here:
451 0           $query->{ 'limit' } = $page_size;
452             }
453              
454 0           my $done = 0;
455 0           my $last_id = undef;
456 0           my $num_returned = 0;
457 0           my $limit_orig = $query->{ 'limit' };
458 0           $query->{ 'limit' } = $page_size + 1;
459              
460 0           my @annotation_buff = ();
461             return sub {
462 0 0 0 0     $done = 1 if (defined $limit_orig and $num_returned >= $limit_orig);
463 0 0 0       QUERY: if (@annotation_buff == 0 && not $done) {
464 0 0         warn "fetching annotations from server.\n" if $VERB > 0;
465             #Need to refill response buffer
466 0           my $url = URI->new( "${\$self->api_url}/search" );
  0            
467 0           $url->query_form($query);
468 0 0         warn $url, "\n" if $VERB > 1;
469 0           my $response = $self->ua->get( $url );
470 0           my $json_content = 0;
471 0 0         if ($response->code != 500) {
472 0           $json_content = try_json_decode($response);
473 0 0         if (not $json_content) {
474 0           die "Was unable to decode JSON content in search.";
475             }
476             } else {
477 0           die "Received status code ${\$response->code} from Hypothes.is in search.";
  0            
478             }
479 0           @annotation_buff = @{$json_content->{ 'rows' }};
  0            
480 0 0 0       if (defined $limit_orig and $limit_orig eq 'Infinity') {
481             # OK, we get the point, but let's get finite.
482 0           $limit_orig = $json_content->{ 'total' };
483 0           $query->{ 'limit' } = $json_content->{ 'total' };
484             }
485 0 0 0       if (not defined $limit_orig or $json_content->{ 'total' } < $limit_orig) {
486             # No limit set or more than total. Set it to the total
487             # so we don't have to try an extra request past the
488             # total number of results
489 0           $limit_orig = $json_content->{ 'total' };
490 0 0         warn "setting limit_orig=$limit_orig based on total\n" if $VERB > 1;
491             }
492 0 0         if (defined $last_id) {
493             # This assumes that the feed is like a stack: LIFO.
494             # Annotations created after the search call
495             # shouldn't be returned.
496             #
497             # This is not the first query because $last_id is set and the
498             # offset arranges so that, without the addition of new
499             # annotations, the first result from the new query will be
500             # the same as the last result of the old query. If it isn't
501             # then we assume that new items have been added to the beginning
502             # and scan forward to find the id. The may be more than one
503             # page of scanning.
504 0   0       while (@annotation_buff and $last_id ne $annotation_buff[0]->{'id'}) {
505 0 0         warn "mismatch: scanning for last seen id\n" if $VERB > 0;
506 0           shift @annotation_buff;
507 0 0         if (@annotation_buff == 0) {
508 0           $query->{ 'offset' } += $page_size;
509 0           goto QUERY;
510             }
511             }
512 0 0         if (@annotation_buff) {
513 0           shift @annotation_buff;
514             }
515             }
516 0           $query->{ 'offset' } += $page_size;
517 0 0         warn $response->content if $VERB > 5;
518             }
519 0 0 0       return undef if ($done or @annotation_buff == 0);
520 0           my $anno = shift @annotation_buff;
521 0           $last_id = $anno->{'id'};
522 0           $num_returned++;
523 0           return $anno;
524             }
525              
526 0           }
527              
528             =head2 search_total(\%query, $page_size)
529              
530             Specific interface to GET /api/search that simply
531             returns the total number of query results. See
532             the search subroutine for more details on parameters.
533              
534             =cut
535              
536             sub search_total {
537              
538             # Note: try to keep the logic here the same as in the search
539             # function, or possibly remove code duplication.
540             #
541             # Start of code duplication:
542             #
543 0     0 1   my ($self, $query, $page_size) = @_;
544              
545 0           my $h = HTTP::Headers->new;
546 0           $h->header(
547             'content-type' => 'application/json;charset=UTF-8',
548             'x-csrf-token' => $self->csrf_token,
549             );
550 0 0         if (not defined $query) {
551 0           $query = {};
552             }
553 0 0         if ( defined $query->{ 'uri' } ) {
554             $query->{ 'uri' } = $self->uri_encoder->encode(
555 0           $query->{ 'uri' }
556             );
557             }
558 0 0         if (not defined $page_size) {
559             #Default at the time, but need to make explicit here:
560 0           $page_size = 20;
561             }
562 0 0         if ( not defined $query->{ 'limit' } ) {
563             #Default at the time, but need to make explicit here:
564 0           $query->{ 'limit' } = $page_size;
565             }
566              
567 0           my $done = 0;
568 0           my $last_id = undef;
569 0           my $num_returned = 0;
570 0           my $limit_orig = $query->{ 'limit' };
571 0           $query->{ 'limit' } = $page_size + 1;
572             #
573             # End of code duplication:
574             #
575              
576 0           my $url = URI->new( "${\$self->api_url}/search" );
  0            
577 0           $url->query_form($query);
578 0 0         warn $url, "\n" if $VERB > 1;
579 0           my $response = $self->ua->get( $url );
580 0           my $json_content = 0;
581 0 0         if ($response->code != 500) {
582 0           $json_content = try_json_decode($response);
583 0 0         if (not $json_content) {
584 0           die "Was unable to decode JSON content in search_total.";
585             }
586             } else {
587 0           die "Received status code ${\$response->code} from Hypothes.is in search_total.";
  0            
588             }
589 0           return $json_content->{ 'total' };
590             }
591              
592              
593             =head2 update_id($id, \%payload)
594              
595             Interface to PUT /api/annotations/<id>
596              
597             Updates the annotation for a given annotation id if id is defined and
598             the user is authenticated and has update permissions. Takes a payload
599             as described for 'search'. Only fields specified in the new payload
600             are altered; other existing fields should remain unchanged.
601              
602             Returns a boolean value indicating whether or not the annotation for
603             that id has been successfully updated (1 = yes, 0 = no).
604              
605             =cut
606              
607             sub update_id {
608 0     0 1   my ($self, $id, $payload) = @_;
609 0 0         if (not defined $id) {
610 0           die "Can only call update if given an id.";
611             }
612 0           my $data = $json->encode($payload);
613 0           my $h = HTTP::Headers->new;
614 0           $h->header(
615             'content-type' => 'application/json;charset=UTF-8',
616             'x-csrf-token' => $self->csrf_token,
617             'X-Annotator-Auth-Token' => $self->token,
618             );
619 0           $self->ua->default_headers( $h );
620 0           my $url = URI->new( "${\$self->api_url}/annotations/$id" );
  0            
621 0           my $response = $self->ua->put( $url, Content => $data );
622 0           my $json_content = 0;
623 0 0         if ($response->code != 500) {
624 0           $json_content = try_json_decode($response);
625 0 0         if (not $json_content) {
626 0           die "Was unable to decode JSON content for update_id, id: $id";
627             }
628             } else {
629 0           die "Received status code ${\$response->code} from Hypothes.is in update_id.";
  0            
630             }
631 0           my $content_type = ref($json_content);
632 0 0         if ($content_type eq "HASH") {
633 0 0         if (defined $json_content->{'updated'}) {
634 0 0         if ($json_content->{'updated'}) {
    0          
635 0           return 1;
636             } elsif (not $json_content->{'updated'}) {
637 0           return 0;
638             } else { # Never reached in current implementation
639 0           warn "unexpected update status: ${\$json_content->{'updated'}}";
  0            
640 0           return 0;
641             }
642             } else {
643 0           die "Received unexpected object: no 'updated' entry present.";
644             }
645             } else {
646 0           die "Got $content_type; expected an ARRAY or HASH.";
647             }
648             }
649              
650              
651             =head1 EXTERNAL ACCESSORS
652              
653             =head2 get_ua_timeout($timeout)
654              
655             Gets the timeout (in seconds) of the internal LWP::UserAgent object used to
656             make requests to the Hypothes.is service.
657              
658             =cut
659              
660             sub get_ua_timeout {
661 0     0 1   my ($self) = @_;
662 0           return $self->ua->timeout;
663             }
664              
665             =head2 set_ua_timeout($timeout)
666              
667             Under certain circumstances, particularly for testing, it is helpful to set
668             the timeout (in seconds) used by LWP::UserAgent to make requests to the
669             Hypothes.is service.
670              
671             =cut
672              
673             sub set_ua_timeout {
674 0     0 1   my ($self, $timeout) = @_;
675 0           $self->ua->timeout( $timeout );
676 0           return;
677             }
678              
679              
680             sub try_json_decode {
681 0     0 0   my ($response) = @_;
682 0           my $json_content = 0;
683             try{
684 0     0     $json_content = $json->decode($response->content);
685             } catch {
686 0     0     warn "Trouble decoding JSON: $_\n";
687 0           warn $response->content;
688 0           };
689 0           return $json_content;
690             }
691              
692              
693              
694             =head1 AUTHOR
695              
696             Brandon E. Barker, C<< <brandon.barker at cornell.edu> >>
697              
698             Created 06/2015
699              
700             Licensed under the Apache License, Version 2.0 (the "Apache License");
701             also licensed under the Artistic License 2.0 (the "Artistic License").
702             you may not use this file except in compliance with one of
703             these two licenses. You may obtain a copy of the Apache License at
704              
705             http://www.apache.org/licenses/LICENSE-2.0
706              
707             Alternatively a copy of the Apache License should be available in the
708             LICENSE-2.0.txt file found in this source code repository.
709              
710             You may obtain a copy of the Artistic License at
711              
712             http://www.perlfoundation.org/artistic_license_2_0
713              
714             Alternatively a copy of the Artistic License should be available in the
715             artistic-2_0.txt file found in this source code repository.
716              
717             Unless required by applicable law or agreed to in writing, software
718             distributed under the License is distributed on an "AS IS" BASIS,
719             WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
720             See the Apache License or Artistic License for the specific language
721             governing permissions and limitations under the licenses.
722              
723             =head1 BUGS
724              
725             Please report any bugs or feature requests at L<https://github.com/bbarker/Hypothesis-API/issues>.
726             Alternatively, you may send them to C<bug-hypothesis-api at rt.cpan.org>, or through
727             the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Hypothesis-API>, but this
728             is not preferred. In either case, I will be notified, and then you'll
729             automatically be notified of progress on your bug as I make changes.
730              
731             =head1 REPOSITORY
732              
733             L<https://github.com/bbarker/Hypothesis-API>
734              
735              
736             =head1 SUPPORT
737              
738             You can find documentation for this module with the perldoc command.
739              
740             perldoc Hypothesis::API
741              
742             You can also look for information at:
743              
744             =over 4
745              
746             =item * RT: CPAN's request tracker (report bugs here)
747              
748             L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Hypothesis-API>
749              
750             =item * AnnoCPAN: Annotated CPAN documentation
751              
752             L<http://annocpan.org/dist/Hypothesis-API>
753              
754             =item * CPAN Ratings
755              
756             L<http://cpanratings.perl.org/d/Hypothesis-API>
757              
758             =item * Search CPAN
759              
760             L<http://search.cpan.org/dist/Hypothesis-API/>
761              
762             =back
763              
764              
765             =head1 ACKNOWLEDGEMENTS
766              
767             We are thankful for support from the Alfred P. Sloan Foundation.
768              
769             =cut
770              
771             1; # End of Hypothesis::API