File Coverage

blib/lib/Net/PMP/Client.pm
Criterion Covered Total %
statement 39 290 13.4
branch 0 110 0.0
condition 0 61 0.0
subroutine 13 43 30.2
pod 18 18 100.0
total 70 522 13.4


line stmt bran cond sub pod time code
1             package Net::PMP::Client;
2 3     3   65117 use Moose;
  3         1188703  
  3         24  
3             with 'MooseX::SimpleConfig';
4 3     3   18448 use Carp;
  3         7  
  3         204  
5 3     3   473 use Data::Dump qw( dump );
  3         3862  
  3         161  
6 3     3   1540 use LWP::UserAgent 6; # SSL verification bug fixed in 6.03
  3         94158  
  3         94  
7 3     3   24 use HTTP::Request;
  3         5  
  3         66  
8 3     3   926 use MIME::Base64;
  3         1514  
  3         167  
9 3     3   1087 use JSON;
  3         17767  
  3         16  
10 3     3   1296 use Net::PMP::AuthToken;
  3         11  
  3         161  
11 3     3   1371 use Net::PMP::CollectionDoc;
  3         8  
  3         102  
12 3     3   946 use Net::PMP::Schema;
  3         7  
  3         85  
13 3     3   864 use Net::PMP::Credentials;
  3         8  
  3         88  
14 3     3   19 use URI;
  3         4  
  3         57  
15 3     3   11 use Try::Tiny;
  3         7  
  3         7455  
16              
17             our $VERSION = '0.006';
18              
19             has '+configfile' =>
20             ( default => $ENV{PMP_CLIENT_CONFIG} || ( $ENV{HOME} . '/.pmp.yaml' ) );
21             has 'host' => (
22             is => 'rw',
23             isa => 'Str',
24             required => 1,
25             default => sub { $ENV{PMP_CLIENT_HOST} || 'https://api-sandbox.pmp.io/' },
26             );
27             has 'id' => ( is => 'rw', isa => 'Str', required => 1, );
28             has 'secret' => ( is => 'rw', isa => 'Str', required => 1, );
29             has 'debug' => ( is => 'rw', isa => 'Bool', default => 0, );
30             has 'ua' => ( is => 'rw', isa => 'LWP::UserAgent', builder => '_init_ua', );
31             has 'pmp_content_type' => (
32             is => 'rw',
33             isa => 'Str',
34             default => sub {'application/vnd.collection.doc+json'},
35             );
36             has 'last_response' => ( is => 'rw', isa => 'HTTP::Response', );
37              
38             # TODO add strict mode where schema validation is enforced client-side on save()
39             #has 'strict' => ( is => 'rw', isa => 'Bool', default => sub {0} );
40              
41             # some constructor-time setup
42             sub BUILD {
43 0     0 1   my $self = shift;
44 0           $self->{host} =~ s/\/$//; # no trailing slash
45 0           $self->{_last_token_ts} = 0;
46 0           $self->get_token(); # initiate connection
47 0           $self->_set_home_doc_config(); # basic introspection
48 0           return $self;
49             }
50              
51             sub _init_ua {
52 0     0     my $self = shift;
53 0           my $ua = LWP::UserAgent->new(
54             agent => 'net-pmp-perl-' . $VERSION,
55             ssl_opts => { verify_hostname => 1 },
56             );
57              
58             # if Compress::Zlib is installed, this should handle gzip transparently.
59             # thanks to
60             # http://stackoverflow.com/questions/1285305/how-can-i-accept-gzip-compressed-content-using-lwpuseragent
61 0           my $can_accept = HTTP::Message::decodable();
62 0           $ua->default_header( 'Accept-Encoding' => $can_accept );
63              
64 0 0         if ( $self->debug ) {
65 0     0     $ua->add_handler( "request_send", sub { shift->dump; return } );
  0            
  0            
66 0     0     $ua->add_handler( "response_done", sub { shift->dump; return } );
  0            
  0            
67             }
68              
69 0           return $ua;
70             }
71              
72             __PACKAGE__->meta->make_immutable;
73              
74             =head1 NAME
75              
76             Net::PMP::Client - Perl client for the Public Media Platform
77              
78             =head1 SYNOPSIS
79              
80             use Net::PMP::Client;
81            
82             my $host = 'https://api-sandbox.pmp.io';
83             my $client_id = 'i-am-a-client';
84             my $client_secret = 'i-am-a-secret';
85              
86             # instantiate a client
87             my $client = Net::PMP::Client->new(
88             host => $host,
89             id => $client_id,
90             secret => $client_secret,
91             );
92              
93             # authenticate
94             my $token = $client->get_token();
95             if ($token->expires_in() < 10) {
96             die "Access token expires too soon. Not enough time to make a request. Mayday, mayday!";
97             }
98             printf("PMP token is: %s\n, $token->as_string());
99              
100             # search
101             my $search_results = $client->search({ tag => 'samplecontent', profile => 'story' });
102             my $results = $search_results->get_items();
103             printf( "total: %s\n", $results->total );
104             while ( my $r = $results->next ) {
105             printf( '%s: %s [%s]', $results->count, $r->get_uri, $r->get_title, ) );
106             }
107            
108             =cut
109              
110             =head1 DESCRIPTION
111              
112             Net::PMP::Client is a Perl client for the Public Media Platform API (http://docs.pmp.io/).
113              
114             =head1 METHODS
115              
116             =head2 new( I<args> )
117              
118             Instantiate a Client object. I<args> may consist of:
119              
120             =over
121              
122             =item host
123              
124             Default is C<https://api-sandbox.pmp.io>.
125              
126             =item id (required)
127              
128             The client id. See L<https://github.com/publicmediaplatform/pmpdocs/wiki/Authenticating-with-the-API#generating-credentials>.
129              
130             =item secret (required)
131              
132             The client secret. See L<https://github.com/publicmediaplatform/pmpdocs/wiki/Authenticating-with-the-API#generating-credentials>.
133              
134             =item debug
135              
136             Boolean. Default is off.
137              
138             =item ua
139              
140             A LWP::UserAgent object.
141              
142             =item pmp_content_type
143              
144             Defaults to C<application/vnd.collection.doc+json>. Change at your peril.
145              
146             =back
147              
148             =head2 BUILD
149              
150             Internal method for object construction.
151              
152             =head2 last_response
153              
154             Returns the most recent HTTP::Response object. Useful for debugging client behaviour.
155              
156             =head2 get_home_doc
157              
158             Returns the CollectionDoc for the API root. This object is cached for performance reasons.
159              
160             =cut
161              
162             sub get_home_doc {
163 0     0 1   my $self = shift;
164 0           return $self->{_home_doc};
165             }
166              
167             =head2 get_token([I<refresh>],[I<warning_ttl>])
168              
169             Returns a Net::PMP::AuthToken object. The optional I<refresh> boolean indicates
170             that the Client should ignore any cached token and fetch a fresh one.
171              
172             If get_home_doc() is undefined (i.e., no initial access has been attempted),
173             then this method will return undef.
174              
175             If the token will expire in less than I<warning_ttl> seconds, the client will sleep()
176             that long and then refresh itself. The default is 10 seconds.
177              
178             =cut
179              
180             sub get_token {
181 0     0 1   my $self = shift;
182 0   0       my $refresh = shift || 0;
183 0   0       my $warning_ttl = shift || 10;
184              
185             # use cache?
186 0 0 0       if ( !$refresh
      0        
187             and $self->{_token}
188             and $self->{_token}->expires_in() > $warning_ttl )
189             {
190 0           my $tok = $self->{_token};
191 0 0         if ( $self->{_last_token_ts} ) {
192             $tok->expires_in(
193 0           $tok->expires_in - ( time() - $self->{_last_token_ts} ) );
194             }
195 0           $self->{_last_token_ts} = time();
196 0           return $tok;
197             }
198              
199 0 0 0       if ( $self->{_token} and $self->{_token}->expires_in() <= $warning_ttl ) {
200 0 0         if ( $self->debug ) {
201             warn sprintf(
202             "Token will expire in %d seconds. Sleeping for that long...\n",
203 0           $self->{_token}->expires_in() );
204             }
205 0           sleep( $self->{_token}->expires_in() + 1 ); # let server side expire
206             }
207              
208             # fetch new token
209 0           my $home_doc = $self->get_home_doc();
210              
211             # we have a chicken-and-egg situation on the first home doc request,
212             # but the home doc doesn't require a token,
213             # so just skip it if not defined.
214 0 0         if ( !$home_doc ) {
215 0           return;
216             }
217 0           my $auth_links = $home_doc->get_links('auth');
218 0           my $uri
219             = $auth_links->rels('urn:collectiondoc:form:issuetoken')->[0]->href;
220 0           my $request = HTTP::Request->new( POST => $uri );
221 0           my $hash = encode_base64( join( ':', $self->id, $self->secret ), '' );
222 0           $request->header( 'Accept' => 'application/json' );
223 0           $request->header( 'Content-Type' => 'application/x-www-form-urlencoded' );
224 0           $request->header( 'Authorization' => 'Basic ' . $hash );
225 0           $request->content('grant_type=client_credentials');
226 0           my $response = $self->ua->request($request);
227              
228 0 0         if ( $response->code != 200 ) {
229 0           croak "Invalid response from authn server: " . $response->status_line;
230             }
231              
232 0           $self->last_response($response);
233              
234             # unpack response
235             my $token = try {
236 0     0     decode_json( $response->decoded_content );
237             }
238             catch {
239 0     0     croak "Invalid authn response: " . $response->decoded_content;
240 0           };
241 0           $self->{_token} = Net::PMP::AuthToken->new($token);
242 0           $self->{_last_token_ts} = time();
243 0           return $self->{_token};
244             }
245              
246             =head2 revoke_token
247              
248             Expires the currently active AuthToken.
249              
250             =cut
251              
252             sub revoke_token {
253 0     0 1   my $self = shift;
254 0           my $auth_links = $self->get_home_doc()->get_links('auth');
255 0           my $uri
256             = $auth_links->rels('urn:collectiondoc:form:revoketoken')->[0]->href;
257 0           my $hash = encode_base64( join( ':', $self->id, $self->secret ), '' );
258 0           my $request = HTTP::Request->new( DELETE => $uri );
259 0           $request->header( 'Authorization' => 'Basic ' . $hash );
260 0           my $response = $self->ua->request($request);
261              
262 0 0         if ( $response->code != 204 ) {
263 0           croak "Invalid response from authn server: " . $response->status_line;
264             }
265 0           $self->{_token} = undef;
266 0           return $self;
267             }
268              
269             =head2 get_credentials_uri
270              
271             Returns the URI for the Credentials API.
272              
273             =cut
274              
275             sub get_credentials_uri {
276 0     0 1   my $self = shift;
277 0           my $auth_links = $self->get_home_doc()->get_links('auth');
278 0           my $uri
279             = $auth_links->rels('urn:collectiondoc:form:createcredentials')->[0]
280             ->href;
281 0           return URI->new($uri);
282             }
283              
284             =head2 create_credentials( I<params> )
285              
286             Instantiates credentials at server. I<params> should be a hash of key/value pairs.
287              
288             =over
289              
290             =item username (required)
291              
292             =item password (required)
293              
294             =item scope (default: read)
295              
296             =item expires (default: 86400)
297              
298             =item label (default: null)
299              
300             =back
301              
302             Returns a Net::PMP::Credentials object.
303              
304             =cut
305              
306             sub create_credentials {
307 0     0 1   my $self = shift;
308 0           my %params = @_;
309 0 0         my $user = delete $params{username} or croak "username required";
310 0 0         my $pass = delete $params{password} or croak "password required";
311              
312             # validate input
313 0           my @valid_params = qw( scope expires label token_expires_in );
314 0           my %post_params;
315 0           for my $p (@valid_params) {
316 0 0 0       if ( exists $params{$p}
      0        
317             and defined $params{$p}
318             and length $params{$p} )
319             {
320 0           $post_params{$p} = delete $params{$p};
321             }
322             }
323              
324             # special case
325 0 0         if ( $post_params{expires} ) {
326 0           $post_params{token_expires_in} = delete $post_params{expires};
327             }
328 0   0       $post_params{label} ||= 'null'; # Net::PMP::Credentials requires it be set
329              
330 0           my $uri = $self->get_credentials_uri();
331 0           my $hash = encode_base64( join( ':', $user, $pass ), '' );
332 0 0         if ( $self->debug ) {
333 0           warn "POST with $user:$pass => $hash\n";
334             }
335 0           my $request = HTTP::Request->new( POST => $uri );
336 0           $request->header( 'Authorization' => 'Basic ' . $hash );
337 0           $request->header( 'Accept' => 'application/json' );
338 0           $request->header( 'Content-Type' => 'application/x-www-form-urlencoded' );
339              
340             # mimic what HTTP::Request::Common does for POST
341 0           my $url = URI->new('http:');
342 0           $url->query_form(%post_params);
343 0           $request->content( $url->query );
344              
345             # send request
346 0           my $response = $self->ua->request($request);
347 0 0         if ( $response->code != 200 ) {
348 0           croak "Invalid response from authn server: " . $response->status_line;
349             }
350 0           $self->last_response($response);
351              
352             # unpack response
353             my $creds = try {
354 0     0     decode_json( $response->decoded_content );
355             }
356             catch {
357 0     0     croak "Invalid authn response: " . $response->decoded_content;
358 0           };
359 0           return Net::PMP::Credentials->new($creds);
360             }
361              
362             =head2 delete_credentials( I<params> )
363              
364             Deletes credentials at the server.
365              
366             I<params> should consist of:
367              
368             =over
369              
370             =item username
371              
372             =item password
373              
374             =item client_id
375              
376             =back
377              
378             =cut
379              
380             sub delete_credentials {
381 0     0 1   my $self = shift;
382 0           my %params = @_;
383 0 0         my $user = $params{username} or croak "username required";
384 0 0         my $pass = $params{password} or croak "password required";
385 0 0         my $client_id = $params{client_id} or croak "client_id required";
386              
387 0           my $uri = $self->get_credentials_uri() . '/' . $client_id;
388 0           my $hash = encode_base64( join( ':', $user, $pass ), '' );
389 0           my $request = HTTP::Request->new( DELETE => $uri );
390 0           $request->header( 'Authorization' => 'Basic ' . $hash );
391 0           $request->header( 'Accept' => 'application/json' );
392 0           $request->header( 'Content-Type' => $self->pmp_content_type );
393              
394             # send request
395 0           my $response = $self->ua->request($request);
396 0 0         if ( $response->code != 204 ) {
397 0           croak "Invalid response from authn server: " . $response->status_line;
398             }
399 0           $self->last_response($response);
400              
401 0           return $response;
402             }
403              
404             =head2 uri_for_doc(I<guid>)
405              
406             Returns full URI for I<guid>.
407              
408             =cut
409              
410             sub uri_for_doc {
411 0     0 1   my $self = shift;
412 0 0         my $guid = shift or croak "guid required";
413 0           return $self->{_home_doc}->query('urn:collectiondoc:hreftpl:docs')
414             ->as_uri( { guid => $guid } );
415             }
416              
417             =head2 uri_for_profile(I<profile>)
418              
419             Returns full URI for I<profile>.
420              
421             =cut
422              
423             sub uri_for_profile {
424 0     0 1   my $self = shift;
425 0 0         my $profile = shift or croak "profile required";
426 0           return sprintf( "%s/profiles/%s", $self->host, $profile );
427             }
428              
429             =head2 uri_for_schema(I<schema>)
430              
431             Returns full URI for I<schema>.
432              
433             =cut
434              
435             sub uri_for_schema {
436 0     0 1   my $self = shift;
437 0 0         my $schema = shift or croak "schema required";
438 0           return sprintf( "%s/schemas/%s", $self->host, $schema );
439             }
440              
441             =head2 get(I<uri>)
442              
443             Issues a GET request on I<uri> and decodes the JSON response into a Perl
444             scalar.
445              
446             If the GET request returns a 404 (Not Found) will return 0 (zero).
447              
448             If the GET request returns anything other than 200, will croak.
449              
450             If the GET request returns 200, will return the JSON response, decoded.
451              
452             =cut
453              
454             sub get {
455 0     0 1   my $self = shift;
456 0 0         my $uri = shift or croak "uri required";
457 0           my $request = HTTP::Request->new( GET => $uri );
458 0           $request->header(
459             'Accept' => 'application/json; ' . $self->pmp_content_type, );
460              
461             # the initial GET of home doc does not require a token.
462 0           my $token = $self->get_token();
463 0 0         if ($token) {
464 0           $request->header( 'Authorization' =>
465             sprintf( '%s %s', $token->token_type, $token->access_token )
466             );
467             }
468 0           my $response = $self->ua->request($request);
469              
470             # retry if 401
471 0 0         if ( $response->code == 401 ) {
472              
473             # occasional persistent 401 errors?
474 0           sleep(1);
475 0           $token = $self->get_token(1);
476 0           $request->header( 'Authorization' =>
477             sprintf( '%s %s', $token->token_type, $token->access_token )
478             );
479              
480             #sleep(1);
481 0           $response = $self->ua->request($request);
482 0 0         $self->debug and warn "retry GET $uri\n" . dump($response);
483             }
484              
485 0           $self->last_response($response);
486              
487 0 0         if ( $response->code == 404 ) {
488 0           return 0;
489             }
490              
491 0 0 0       if ( $response->code != 200 or !$response->decoded_content ) {
492 0           croak "Unexpected response for GET $uri: " . $response->status_line;
493             }
494              
495             my $json = try {
496 0     0     decode_json( $response->decoded_content );
497             }
498             catch {
499 0     0     croak "Invalid JSON in response: $@ : " . $response->decoded_content;
500 0           };
501 0           return $json;
502             }
503              
504             sub _set_home_doc_config {
505 0     0     my $self = shift;
506 0   0       $self->{_home_doc} ||= $self->get_doc();
507 0 0         if ( !$self->{_home_doc} ) {
508 0           confess "Failed to GET home doc from " . $self->host;
509             }
510 0           my $edit_links = $self->{_home_doc}->get_links('edit');
511             $self->{_doc_edit_link}
512 0           = $edit_links->rels("urn:collectiondoc:form:documentsave")->[0];
513             }
514              
515             =head2 get_doc_edit_link
516              
517             Retrieves the base doc edit link object for the API.
518              
519             =cut
520              
521             sub get_doc_edit_link {
522 0     0 1   my $self = shift;
523 0 0         return $self->{_doc_edit_link} if $self->{_doc_edit_link};
524 0           $self->_set_home_doc_config();
525 0           return $self->{_doc_edit_link};
526             }
527              
528             =head2 put(I<doc_object>)
529              
530             Write I<doc_object> to the server. I<doc_object> should be an instance
531             of L<Net::PMP::CollectionDoc>.
532              
533             Returns the JSON response from the server on success, croaks on failure.
534              
535             Normally you should use save() instead of put() directly, since save()
536             optionally validates the I<doc_object> before calling put() and makes
537             sure there is a B<guid> and B<href> defined.
538              
539             =cut
540              
541             sub put {
542 0     0 1   my $self = shift;
543 0 0         my $doc = shift or croak "doc required";
544 0 0 0       if ( !blessed $doc or !$doc->isa('Net::PMP::CollectionDoc') ) {
545 0           croak "doc must be a Net::PMP::CollectionDoc object";
546             }
547 0           my $uri = $doc->get_publish_uri( $self->get_doc_edit_link );
548 0           my $request = HTTP::Request->new( PUT => $uri );
549 0           my $token = $self->get_token();
550 0           my $body = $doc->as_json();
551 0 0         if ( $self->debug ) {
552 0           warn "PUT $uri\n" . dump( $doc->as_hash() ) . "\n";
553 0           warn "JSON: $body\n";
554             }
555 0           $request->header( 'Accept' => 'application/json' );
556 0           $request->header( 'Content-Type' => $self->pmp_content_type );
557 0           $request->header( 'Authorization' =>
558             sprintf( '%s %s', $token->token_type, $token->access_token ) );
559 0           $request->content($body);
560 0           my $response = $self->ua->request($request);
561              
562             # retry if 401
563 0 0         if ( $response->code == 401 ) {
564              
565             # occasional persistent 401 errors?
566 0           sleep(1);
567 0           $token = $self->get_token(1);
568 0           $request->header( 'Authorization' =>
569             sprintf( '%s %s', $token->token_type, $token->access_token )
570             );
571              
572             #sleep(1);
573 0           $response = $self->ua->request($request);
574 0 0         $self->debug and warn "retry PUT $uri\n" . dump($response);
575             }
576              
577 0           $self->last_response($response);
578              
579 0 0 0       if ( $response->code !~ m/^20[02]$/ or !$response->decoded_content ) {
580 0           croak sprintf( "Unexpected response for PUT %s: %s\n%s\n",
581             $uri, $response->status_line, $response->content );
582             }
583              
584             my $json = try {
585 0     0     decode_json( $response->decoded_content );
586             }
587             catch {
588 0     0     croak "Invalid JSON in response: $_ : " . $response->decoded_content;
589 0           };
590 0           return $json;
591             }
592              
593             =head2 delete(I<doc_object>)
594              
595             Remove I<doc_object> from the server. Returns true on success, croaks on failure.
596              
597             =cut
598              
599             sub delete {
600 0     0 1   my $self = shift;
601 0 0         my $doc = shift or croak "doc required";
602 0 0 0       if ( !blessed $doc or !$doc->isa('Net::PMP::CollectionDoc') ) {
603 0           croak "doc must be a Net::PMP::CollectionDoc object";
604             }
605 0           my $uri = $doc->get_publish_uri( $self->get_doc_edit_link );
606 0           my $request = HTTP::Request->new( DELETE => $uri );
607 0           my $token = $self->get_token();
608 0           $request->header( 'Accept' => 'application/json' );
609 0           $request->header( 'Content-Type' => $self->pmp_content_type );
610 0           $request->header( 'Authorization' =>
611             sprintf( '%s %s', $token->token_type, $token->access_token ) );
612 0           my $response = $self->ua->request($request);
613              
614             # retry if 401
615 0 0         if ( $response->code == 401 ) {
616              
617             # occasional persistent 401 errors?
618 0           sleep(1);
619 0           $token = $self->get_token(1);
620 0           $request->header( 'Authorization' =>
621             sprintf( '%s %s', $token->token_type, $token->access_token )
622             );
623              
624 0           $response = $self->ua->request($request);
625 0 0         $self->debug and warn "retry DELETE $uri\n" . dump($response);
626             }
627              
628 0           $self->last_response($response);
629              
630 0 0         if ( $response->code != 204 ) {
631 0           croak sprintf( "Unexpected response for DELETE %s: %s\n%s\n",
632             $uri, $response->status_line, $response->content );
633             }
634 0           return 1;
635             }
636              
637             =head2 get_doc( [I<uri>] [,I<tries>] )
638              
639             Returns a Net::PMP::CollectionDoc representing I<uri>. Defaults
640             to the API base endpoint if I<uri> is omitted or false.
641              
642             If I<uri> is not found, returns 0 (zero) just like get().
643              
644             The second, optional parameter I<tries> indicates how many re-tries should
645             be attempted when the response is a 404. This feature helps compenstate
646             for occasional latency on the server between an initial save and subsequent
647             read, since PUT and DELETE requests always return a 202 (accepted but not
648             necessarily acted upon). The default is 1 try.
649              
650             =cut
651              
652             sub get_doc {
653 0     0 1   my $self = shift;
654 0   0       my $uri = shift || $self->host;
655 0   0       my $tries = shift || 1;
656              
657             # optimize a little for the root doc
658 0 0 0       if ( $uri eq $self->host and $self->{_home_doc} ) {
659 0           return $self->{_home_doc};
660             }
661              
662 0           my $response;
663 0           my $attempts = 0;
664 0   0       while ( !$response and $attempts++ < $tries ) {
665 0           $response = $self->get($uri);
666 0 0         $self->debug and warn dump $response;
667 0 0 0       if ( !$response and $attempts < $tries ) {
668 0 0         $self->debug
669             and warn "search returned 404 - sleeping and trying again\n";
670 0           sleep(1);
671             }
672             }
673              
674 0 0         return $response unless $response; # 404
675              
676             # convert JSON response into a CollectionDoc
677             # check content type to determine object
678 0 0         if ( $self->last_response->content_type eq 'application/schema+json' ) {
679 0           return Net::PMP::Schema->new($response);
680             }
681              
682 0           my $doc = Net::PMP::CollectionDoc->new($response);
683              
684 0           return $doc;
685             }
686              
687             =head2 get_doc_by_guid(I<guid>)
688              
689             Like get_doc() but takes a I<guid> as argument.
690              
691             =cut
692              
693             sub get_doc_by_guid {
694 0     0 1   my $self = shift;
695 0 0         my $guid = shift or croak "guid required";
696 0           return $self->get_doc( $self->uri_for_doc($guid) );
697             }
698              
699             =head2 search( I<opts> [,I<tries>] )
700              
701             Search in the 'urn:collectiondoc:query:docs' namespace.
702              
703             Returns a Net::PMP::CollectionDoc object for I<opts>.
704             I<opts> are passed directly to the query link URI template.
705             See L<https://github.com/publicmediaplatform/pmpdocs/wiki/Query-Link-Relation>.
706              
707             The second, optional parameter I<tries> is passed internally to get_doc().
708             See the description of get_doc().
709              
710             =cut
711              
712             sub search {
713 0     0 1   my $self = shift;
714 0 0         my $opts = shift or croak "options required";
715 0   0       my $tries = shift || 1;
716 0           my $uri = $self->{_home_doc}->query('urn:collectiondoc:query:docs')
717             ->as_uri($opts);
718              
719             # debugging option
720 0 0 0       if ( $ENV{PMP_CLIENT_DEBUG} and $ENV{PMP_APPEND_RANDOM_STRING} ) {
721 0           my $rand_guid = Net::PMP::CollectionDoc->create_guid();
722 0           $uri .= '&random=' . $rand_guid;
723             }
724              
725 0           return $self->get_doc( $uri, $tries );
726             }
727              
728             =head2 save(I<doc_object>)
729              
730             Write I<doc_object> to the server. I<doc_object> may be a L<Net::PMP::Profile> object,
731             in which case the as_doc() method is called on it, or it may be a L<Net::PMP::CollectionDoc> object.
732              
733             Returns a L<Net::PMP::CollectionDoc> object with its URI updated to reflect the server response.
734              
735             =cut
736              
737             sub save {
738 0     0 1   my $self = shift;
739 0 0         my $doc = shift or croak "doc object required";
740 0 0 0       if ( blessed $doc and $doc->isa('Net::PMP::Profile') ) {
741 0           $doc = $doc->as_doc();
742             }
743 0 0 0       if ( !blessed $doc or !$doc->isa('Net::PMP::CollectionDoc') ) {
744 0           croak "doc must be a Net::PMP::CollectionDoc object";
745             }
746              
747             # if $doc has no guid (necessary for PUT) create one
748 0 0         if ( !$doc->get_guid ) {
749 0           $doc->set_guid();
750             }
751              
752             # similar for href
753 0 0         if ( !$doc->href ) {
754 0           $doc->href( $self->uri_for_doc( $doc->get_guid ) );
755             }
756              
757 0           my $saved = $self->put($doc);
758 0 0         $self->debug and warn dump $saved;
759              
760 0           $doc->set_uri( $saved->{url} );
761              
762 0           return $doc;
763             }
764              
765             1;
766              
767             __END__
768              
769             =head1 AUTHOR
770              
771             Peter Karman, C<< <karman at cpan.org> >>
772              
773             =head1 BUGS
774              
775             Please report any bugs or feature requests to C<bug-net-pmp at rt.cpan.org>, or through
776             the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Net-PMP>. I will be notified, and then you'll
777             automatically be notified of progress on your bug as I make changes.
778              
779              
780             =head1 SUPPORT
781              
782             You can find documentation for this module with the perldoc command.
783              
784             perldoc Net::PMP::Client
785              
786              
787             You can also look for information at:
788              
789             =over 4
790              
791             =item * RT: CPAN's request tracker (report bugs here)
792              
793             L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Net-PMP>
794              
795             =item * AnnoCPAN: Annotated CPAN documentation
796              
797             L<http://annocpan.org/dist/Net-PMP>
798              
799             =item * CPAN Ratings
800              
801             L<http://cpanratings.perl.org/d/Net-PMP>
802              
803             =item * Search CPAN
804              
805             L<http://search.cpan.org/dist/Net-PMP/>
806              
807             =back
808              
809              
810             =head1 ACKNOWLEDGEMENTS
811              
812             American Public Media and the Public Media Platform sponsored the development of this module.
813              
814             =head1 LICENSE AND COPYRIGHT
815              
816             Copyright 2013 American Public Media Group
817              
818             See the LICENSE file that accompanies this module.
819              
820             =cut