File Coverage

blib/lib/Catmandu/Importer/OAI.pm
Criterion Covered Total %
statement 13 15 86.6
branch n/a
condition n/a
subroutine 5 5 100.0
pod n/a
total 18 20 90.0


line stmt bran cond sub pod time code
1             package Catmandu::Importer::OAI;
2              
3 4     4   51337 use Catmandu::Sane;
  4         466149  
  4         20  
4 4     4   771 use Catmandu::Util qw(:is);
  4         5  
  4         957  
5 4     4   19 use Moo;
  4         8  
  4         12  
6 4     4   979 use Scalar::Util qw(blessed);
  4         3  
  4         151  
7 4     4   1430 use HTTP::OAI;
  0            
  0            
8             use Carp;
9             use Catmandu::Error;
10             use URI;
11              
12             our $VERSION = '0.17';
13              
14             with 'Catmandu::Importer';
15              
16             has url => (is => 'ro', required => 1);
17             has identifier => (is => 'ro');
18             has metadataPrefix => (is => 'ro', default => sub { "oai_dc" });
19             has set => (is => 'ro');
20             has from => (is => 'ro');
21             has until => (is => 'ro');
22             has resumptionToken => (is => 'ro');
23              
24             has identify => (is => 'ro');
25             has listIdentifiers => (is => 'ro');
26             has listRecords => (is => 'ro');
27             has listSets => (is => 'ro');
28             has listMetadataFormats => (is => 'ro');
29             has getRecord => (is => 'ro');
30              
31             has oai => (is => 'ro', lazy => 1, builder => 1);
32             has dry => (is => 'ro');
33             has handler => (is => 'rw', lazy => 1 , builder => 1, coerce => \&_coerce_handler );
34             has xslt => (is => 'ro', coerce => \&_coerce_xslt );
35             has max_retries => ( is => 'ro', default => sub { 0 } );
36             has _retried => ( is => 'rw', default => sub { 0; } );
37             has _xml_handlers => ( is => 'ro', default => sub { +{} } );
38             has realm => ( is => 'ro', predicate => 1 );
39             has username => ( is => 'ro', predicate => 1 );
40             has password => ( is => 'ro', predicate => 1 );
41              
42             sub _build_handler {
43             my ($self) = @_;
44             if ($self->metadataPrefix eq 'oai_dc') {
45             return 'oai_dc';
46             }
47             elsif ($self->metadataPrefix eq 'marcxml') {
48             return 'marcxml';
49             }
50             elsif ($self->metadataPrefix eq 'mods') {
51             return 'mods';
52             }
53             else {
54             return 'struct';
55             }
56             }
57              
58             sub _coerce_handler {
59             my ($handler) = @_;
60              
61             return $handler if is_invocant($handler) or is_code_ref($handler);
62              
63             if (is_string($handler) && !is_number($handler)) {
64             my $class = $handler =~ /^\+(.+)/ ? $1
65             : "Catmandu::Importer::OAI::Parser::$handler";
66              
67             my $handler;
68             eval {
69             $handler = Catmandu::Util::require_package($class)->new;
70             };
71             if ($@) {
72             croak $@;
73             } else {
74             return $handler;
75             }
76             }
77              
78             return sub { return { _metadata => readXML($_[0]) } };
79             }
80              
81             sub _coerce_xslt {
82             eval {
83             Catmandu::Util::require_package('Catmandu::XML::Transformer')
84             ->new( stylesheet => $_[0] )
85             } or croak $@;
86             }
87              
88             sub _build_oai {
89             my ($self) = @_;
90             my $agent = HTTP::OAI::Harvester->new(baseURL => $self->url, resume => 0, keep_alive => 1);
91             if( $self->has_username && $self->has_password ) {
92             my $uri = URI->new( $self->url );
93             my @credentials = (
94             $uri->host_port,
95             $self->realm || undef,
96             $self->username,
97             $self->password
98             );
99             $agent->credentials( @credentials );
100             }
101             $agent->env_proxy;
102             $agent;
103             }
104              
105             sub _xml_handler_for_node {
106             my ( $self, $node ) = @_;
107             my $ns = $node->namespaceURI();
108              
109             my $type;
110              
111             if( $ns eq "http://www.openarchives.org/OAI/2.0/oai_dc/" ){
112              
113             $type = "oai_dc";
114              
115             }
116             elsif( $ns eq "http://www.loc.gov/MARC21/slim" ){
117              
118             $type = "marcxml";
119              
120             }
121             elsif( $ns eq "http://www.loc.gov/mods/v3" ){
122              
123             $type = "mods";
124              
125             }
126             else{
127              
128             $type = "struct";
129              
130             }
131              
132             $self->_xml_handlers()->{$type} ||= Catmandu::Util::require_package( "Catmandu::Importer::OAI::Parser::$type" )->new();
133             }
134              
135             sub _map_set {
136             my ($self, $rec) = @_;
137              
138             +{
139             _id => $rec->setSpec(),
140             setSpec => $rec->setSpec(),
141             setName => $rec->setName(),
142             setDescription => [ map {
143              
144             #root: 'setDescription'
145             my @root = $_->dom()->childNodes();
146             #child: oai_dc, marcxml, mods..
147             my @children = $root[0]->childNodes();
148             $self->_xml_handler_for_node( $children[0] )->parse( $children[0] );
149              
150             } $rec->setDescription() ]
151             };
152             }
153              
154             sub _map_format {
155             my ($self, $rec) = @_;
156              
157             +{
158             _id => $rec->metadataPrefix,
159             metadataPrefix => $rec->metadataPrefix(),
160             metadataNamespace => $rec->metadataNamespace(),
161             schema => $rec->schema()
162             };
163             }
164              
165             sub _map_identify {
166             my ($self, $rec) = @_;
167              
168             my @description;
169              
170             if ($rec->description) {
171             for my $desc ($rec->description) {
172             push @description , $desc->dom->toString;
173             }
174             }
175              
176             +{
177             _id => $rec->baseURL,
178             baseURL => $rec->baseURL,
179             granularity => $rec->granularity,
180             deletedRecord => $rec->deletedRecord,
181             earliestDatestamp => $rec->earliestDatestamp,
182             adminEmail => $rec->adminEmail,
183             protocolVersion => $rec->protocolVersion,
184             repositoryName => $rec->repositoryName,
185             description => \@description
186             };
187             }
188              
189              
190             sub _map_record {
191             my ($self, $rec) = @_;
192              
193             my $sets = [ $rec->header->setSpec ];
194             my $identifier = $rec->identifier;
195             my $datestamp = $rec->datestamp;
196             my $status = $rec->status // "";
197             my $dom = $rec->metadata ? $rec->metadata->dom->nonBlankChildNodes->[0]->nonBlankChildNodes->[0] : undef;
198             my $about = [];
199              
200             for ($rec->about) {
201             push(@$about , $_->dom->nonBlankChildNodes->[0]->nonBlankChildNodes->[0]->toString);
202             }
203              
204             my $values = $self->handle_record($dom) // { };
205              
206             my $data = {
207             _id => $identifier ,
208             _identifier => $identifier ,
209             _datestamp => $datestamp ,
210             _status => $status ,
211             _setSpec => $sets ,
212             _about => $about ,
213             %$values
214             };
215              
216             $data;
217             }
218              
219             sub _args_for_records {
220             my $self = $_[0];
221              
222             my %args = (
223             identifier => $self->identifier,
224             metadataPrefix => $self->metadataPrefix,
225             set => $self->set ,
226             from => $self->from ,
227             until => $self->until ,
228             );
229              
230             for( keys %args ) {
231             delete $args{$_} if !defined($args{$_}) || !length($args{$_});
232             }
233              
234             return %args;
235             }
236              
237             sub _args {
238             my $self = $_[0];
239              
240             my %args;
241              
242             if( $self->listSets() ){
243              
244             }
245             else{
246             %args = $self->_args_for_records();
247             }
248              
249             %args;
250             }
251              
252             sub _verb {
253             my $self = $_[0];
254              
255             if ($self->identify) {
256             return 'Identify';
257             }
258             elsif ($self->listIdentifiers) {
259             return 'ListIdentifiers';
260             }
261             elsif ($self->listSets) {
262             return 'ListSets';
263             }
264             elsif ($self->getRecord) {
265             return 'GetRecord';
266             }
267             elsif ($self->listMetadataFormats) {
268             return 'ListMetadataFormats';
269             }
270             elsif ($self->listRecords) {
271             return 'ListRecords';
272             }
273             else {
274             return 'ListRecords';
275             }
276             }
277              
278             sub handle_record {
279             my ($self, $dom) = @_;
280             return unless $dom;
281              
282             $dom = $self->xslt->transform($dom) if $self->xslt;
283             return blessed($self->handler)
284             ? $self->handler->parse($dom)
285             : $self->handler->($dom);
286             }
287              
288             sub dry_run {
289             my ($self) = @_;
290             sub {
291             state $called = 0;
292             return if $called;
293             $called = 1;
294             # TODO: make sure that HTTP::OAI does not change this internal method
295             return +{
296             url => $self->oai->_buildurl(
297             $self->_args(),
298             verb => $self->_verb()
299             )
300             };
301             };
302             }
303              
304             sub _retry {
305             my ( $self, $sub ) = @_;
306              
307             $self->_retried( 0 );
308              
309             my $res;
310              
311             while ( 1 ) {
312              
313             $res = $sub->();
314              
315             if ($res->is_error) {
316              
317             my $max_retries = $self->max_retries();
318             my $_retried = $self->_retried();
319              
320             if ( $max_retries > 0 && $_retried < $max_retries ){
321              
322             $_retried++;
323              
324             #exponential backoff: [0 .. 2^c [
325             my $n_seconds = int( 2**$_retried );
326             $self->log->error("failed, retrying after $n_seconds");
327             sleep $n_seconds;
328             $self->_retried( $_retried );
329             next;
330             }
331             else{
332             my $err_msg = $self->url . " : " . $res->message." (stopped after ".$self->_retried()." retries)";
333             $self->log->error( $err_msg );
334             Catmandu::Error->throw( $err_msg );
335             }
336             }
337              
338             last;
339             }
340              
341             $res;
342             }
343              
344             sub _list_records {
345             my $self = $_[0];
346             sub {
347             state $stack = [];
348             state $resumptionToken = $self->resumptionToken;
349             state $done = 0;
350              
351             my $fill_stack = sub {
352             push @$stack , shift;
353             };
354              
355             if (@$stack <= 1 && $done == 0) {
356             my %args = $self->_args;
357              
358             # Use the resumptionToken if one found on the last run, or if it was
359             # undefined (last record)
360             if (defined $resumptionToken) {
361             my $verb = $args{verb};
362             %args = (verb => $verb , resumptionToken => $resumptionToken);
363             }
364              
365             my $sub = $self->listIdentifiers() ?
366             sub { $self->oai->ListIdentifiers( %args , onRecord => $fill_stack ); } :
367             sub { $self->oai->ListRecords( %args , onRecord => $fill_stack ); };
368              
369             my $res = $self->_retry( $sub );
370             if (defined $res->resumptionToken) {
371             $resumptionToken = $res->resumptionToken->resumptionToken;
372             }
373             else {
374             $resumptionToken = undef;
375             }
376              
377             unless (defined $resumptionToken && length $resumptionToken) {
378             $done = 1;
379             }
380             }
381              
382             if (my $rec = shift @$stack) {
383             if ($rec->isa('HTTP::OAI::Record')) {
384             return $self->_map_record($rec);
385             }
386             else {
387             return {
388             _id => $rec->identifier,
389             _datestamp => $rec->datestamp,
390             _status => $rec->status // "",
391             }
392             }
393             }
394              
395             return undef;
396             };
397             }
398              
399             sub _list_sets {
400             my $self = $_[0];
401             sub {
402             state $stack = [];
403             state $done = 0;
404              
405             my $fill_stack = sub {
406             push @$stack , shift;
407             };
408              
409             if (@$stack <= 1 && $done == 0) {
410             my %args = $self->_args;
411              
412             my $sub = sub { $self->oai->ListSets( onRecord => $fill_stack ); };
413              
414             my $res = $self->_retry( $sub );
415             $done = 1;
416             }
417              
418             if (my $rec = shift @$stack) {
419             return $self->_map_set($rec);
420             }
421              
422             return undef;
423             };
424             }
425              
426             sub _get_record {
427             my $self = $_[0];
428             sub {
429             state $stack = [];
430             state $done = 0;
431              
432             my $fill_stack = sub {
433             push @$stack , shift;
434             };
435              
436             if (@$stack <= 1 && $done == 0) {
437             my %args = $self->_args;
438             my $sub = sub { $self->oai->GetRecord(%args , onRecord => $fill_stack) };
439             my $res = $self->_retry( $sub );
440             $done = 1;
441             }
442              
443             if (my $rec = shift @$stack) {
444             if ($rec->isa('HTTP::OAI::Record')) {
445             return $self->_map_record($rec);
446             }
447             else {
448             return {
449             _id => $rec->identifier,
450             _datestamp => $rec->datestamp,
451             _status => $rec->status // "",
452             }
453             }
454             }
455              
456             return undef;
457             };
458             }
459              
460             sub _list_metadata_formats {
461             my $self = $_[0];
462             sub {
463             state $stack = [];
464             state $done = 0;
465              
466             my $fill_stack = sub {
467             push @$stack , shift;
468             };
469              
470             if (@$stack <= 1 && $done == 0) {
471             my %args = $self->_args;
472             delete $args{metadataPrefix};
473              
474             my $sub = sub { $self->oai->ListMetadataFormats( %args ); };
475              
476             my $res = $self->_retry( $sub );
477              
478             while( my $mdf = $res->next ) {
479             $fill_stack->($mdf);
480             }
481              
482             $done = 1;
483             }
484              
485             if (my $rec = shift @$stack) {
486             return $self->_map_format($rec);
487             }
488              
489             return undef;
490             };
491             }
492              
493             sub _identify {
494             my $self = $_[0];
495             sub {
496             state $stack = [];
497             state $done = 0;
498              
499             my $fill_stack = sub {
500             push @$stack , shift;
501             };
502              
503             if (@$stack <= 1 && $done == 0) {
504             my $sub = sub { $self->oai->Identify( onRecord => $fill_stack) };
505             my $res = $self->_retry( $sub );
506              
507             $fill_stack->($res);
508              
509             $done = 1;
510             }
511              
512             if (my $rec = shift @$stack) {
513             return $self->_map_identify($rec);
514             }
515              
516             return undef;
517             };
518             }
519              
520             sub oai_run {
521             my ($self) = @_;
522              
523             if ($self->identify) {
524             return $self->_identify;
525             }
526             elsif ($self->listIdentifiers) {
527             return $self->_list_records;
528             }
529             elsif ($self->listSets) {
530             return $self->_list_sets
531             }
532             elsif ($self->getRecord) {
533             return $self->_get_record;
534             }
535             elsif ($self->listMetadataFormats) {
536             return $self->_list_metadata_formats;
537             }
538             elsif ($self->listRecords) {
539             return $self->_list_records
540             }
541             else {
542             return $self->_list_records
543             }
544             }
545              
546             sub generator {
547             my ($self) = @_;
548              
549             return $self->dry ? $self->dry_run : $self->oai_run;
550             }
551              
552             1;
553             __END__
554              
555             =head1 NAME
556              
557             Catmandu::Importer::OAI - Package that imports OAI-PMH feeds
558              
559             =head1 SYNOPSIS
560              
561             # From the command line
562              
563             # Harvest records
564             $ catmandu convert OAI --url http://myrepo.org/oai
565             $ catmandu convert OAI --url http://myrepo.org/oai --metadataPrefix didl --handler raw
566              
567             # Harvest repository description
568             $ catmandu convert OAI --url http://myrepo.org/oai --identify 1
569              
570             # Harvest identifiers
571             $ catmandu convert OAI --url http://myrepo.org/oai --listIdentifiers 1
572              
573             # Harvest sets
574             $ catmandu convert OAI --url http://myrepo.org/oai --listSets 1
575              
576             # Harvest metadataFormats
577             $ catmandu convert OAI --url http://myrepo.org/oai --listMetadataFormats 1
578              
579             # Harvest one record
580             $ catmandu convert OAI --url http://myrepo.org/oai --getRecord 1 --identifier oai:myrepo:1234
581              
582             =head1 DESCRIPTION
583              
584             L<Catmandu::Importer::OAI> is an L<Catmandu> importer to harvest metadata records
585             from an OAI-PMH endpoint.
586              
587             =head1 CONFIGURATION
588              
589             =over
590              
591             =item url
592              
593             OAI-PMH Base URL.
594              
595             =item metadataPrefix
596              
597             Metadata prefix to specify the metadata format. Set to C<oai_dc> by default.
598              
599             =item handler( sub {} | $object | 'NAME' | '+NAME' )
600              
601             Handler to transform each record from XML DOM (L<XML::LibXML::Element>) into
602             Perl hash.
603              
604             Handlers can be provided as function reference, an instance of a Perl
605             package that implements 'parse', or by a package NAME. Package names should
606             be prepended by C<+> or prefixed with C<Catmandu::Importer::OAI::Parser>. E.g
607             C<foobar> will create a C<Catmandu::Importer::OAI::Parser::foobar> instance.
608              
609             By default the handler L<Catmandu::Importer::OAI::Parser::oai_dc> is used for
610             metadataPrefix C<oai_dc>, L<Catmandu::Importer::OAI::Parser::marcxml> for
611             C<marcxml>, L<Catmandu::Importer::OAI::Parser::mods> for
612             C<mods>, and L<Catmandu::Importer::OAI::Parser::struct> for other formats.
613             In addition there is L<Catmandu::Importer::OAI::Parser::raw> to return the XML
614             as it is.
615              
616             =item identifier
617              
618             Option return only results for this particular identifier
619              
620             =item set
621              
622             An optional set for selective harvesting.
623              
624             =item from
625              
626             An optional datetime value (YYYY-MM-DD or YYYY-MM-DDThh:mm:ssZ) as lower bound
627             for datestamp-based selective harvesting.
628              
629             =item until
630              
631             An optional datetime value (YYYY-MM-DD or YYYY-MM-DDThh:mm:ssZ) as upper bound
632             for datestamp-based selective harvesting.
633              
634             =item identify
635              
636             Harvest the repository description instead of all records.
637              
638             =item getRecord
639              
640             Harvest one record instead of all records.
641              
642             =item listIdentifiers
643              
644             Harvest identifiers instead of full records.
645              
646             =item listRecords
647              
648             Harvest full records. Default operation.
649              
650             =item listSets
651              
652             Harvest sets instead of records.
653              
654             =item listMetadataFormats
655              
656             Harvest metadata formats of records
657              
658             =item resumptionToken
659              
660             An optional resumptionToken to start harvesting from.
661              
662             =item dry
663              
664             Don't do any HTTP requests but return URLs that data would be queried from.
665              
666             =item xslt
667              
668             Preprocess XML records with XSLT script(s) given as comma separated list or
669             array reference. Requires L<Catmandu::XML>.
670              
671             =item max_retries
672              
673             When an oai request fails, the importer will retry this number of times.
674             Set to '0' by default.
675              
676             Internally the exponential backoff algorithm is used
677             for this. This means that after every failed request the importer
678             will choose a random number between 0 and 2^collision (excluded),
679             and wait that number of seconds. So the actual ammount of time before
680             the importer stops can differ:
681              
682             first retry:
683             wait [ 0..2^1 [ seconds
684             second retry:
685             wait [ 0..2^2 [ seconds
686             third retry:
687             wait [ 0..2^3 [ seconds
688              
689             ..
690              
691             =item realm
692              
693             An optional realm value. This value is used when the importer harvests from a
694             repository which is secured with basic authentication through Integrated Windows
695             Authentication (NTLM or Kerberos).
696              
697             =item username
698              
699             An optional username value. This value is used when the importer harvests from a
700             repository which is secured with basic authentication.
701              
702             =item password
703              
704             An optional password value. This value is used when the importer harvests from a
705             repository which is secured with basic authentication.
706              
707             =back
708              
709             =head1 METHOD
710              
711             Every Catmandu::Importer is a L<Catmandu::Iterable> all its methods are
712             inherited. The Catmandu::Importer::OAI methods are not idempotent: OAI-PMH
713             feeds can only be read once.
714              
715             In addition to methods inherited from L<Catmandu::Iterable>, this module
716             provides the following public methods:
717              
718             =head2 handle_record( $dom )
719              
720             Process an XML DOM as with xslt and handler as configured and return the
721             result.
722              
723             =head1 ENVIRONMENT
724              
725             If you are connected to the internet via a proxy server you need to set the
726             coordinates to this proxy in your environment:
727              
728             export http_proxy="http://localhost:8080"
729              
730             If you are connecting to a HTTPS server and don't want to verify the validity
731             of certificates of the peer you can set the PERL_LWP_SSL_VERIFY_HOSTNAME to
732             false in your environment. This maybe required to connect to broken SSL servers:
733              
734             export PERL_LWP_SSL_VERIFY_HOSTNAME=0
735              
736             =head1 SEE ALSO
737              
738             L<Catmandu> ,
739             L<Catmandu::Importer>
740              
741             =head1 AUTHOR
742              
743             Nicolas Steenlant, C<< <nicolas.steenlant at ugent.be> >>
744              
745             =head1 CONTRIBUTOR
746              
747             Patrick Hochstenbach, C<< <patrick.hochstenbach at ugent.be> >>
748              
749             Jakob Voss, C<< <nichtich at cpan.org> >>
750              
751             Nicolas Franck, C<< <nicolas.franck at ugent.be> >>
752              
753             =head1 LICENSE AND COPYRIGHT
754              
755             Copyright 2016 Ghent University Library
756              
757             This program is free software; you can redistribute it and/or modify it
758             under the terms of either: the GNU General Public License as published
759             by the Free Software Foundation; or the Artistic License.
760              
761             See http://dev.perl.org/licenses/ for more information.
762              
763             =cut