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