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 5     5   147455 use Catmandu::Sane;
  5         673602  
  5         35  
4 5     5   1367 use Catmandu::Util qw(:is);
  5         13  
  5         1597  
5 5     5   38 use Moo;
  5         12  
  5         29  
6 5     5   2095 use Scalar::Util qw(blessed);
  5         9  
  5         292  
7 5     5   1755 use HTTP::OAI;
  0            
  0            
8             use Carp;
9             use Catmandu::Error;
10             use URI;
11              
12             our $VERSION = '0.18';
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             sub _map_record {
190             my ($self, $rec) = @_;
191              
192             my $sets = [ $rec->header->setSpec ];
193             my $identifier = $rec->identifier;
194             my $datestamp = $rec->datestamp;
195             my $status = $rec->status // "";
196             my $dom = $rec->metadata ? $rec->metadata->dom->nonBlankChildNodes->[0]->nonBlankChildNodes->[0] : undef;
197             my $about = [];
198              
199             for ($rec->about) {
200             push(@$about , $_->dom->nonBlankChildNodes->[0]->nonBlankChildNodes->[0]->toString);
201             }
202              
203             my $values = $self->handle_record($dom) // { };
204              
205             my $data = {
206             _id => $identifier ,
207             _identifier => $identifier ,
208             _datestamp => $datestamp ,
209             _status => $status ,
210             _setSpec => $sets ,
211             _about => $about ,
212             %$values
213             };
214              
215             $data;
216             }
217              
218             sub _args {
219             my $self = $_[0];
220              
221             my %args = (
222             identifier => $self->identifier,
223             metadataPrefix => $self->metadataPrefix,
224             set => $self->set ,
225             from => $self->from ,
226             until => $self->until ,
227             );
228              
229             for( keys %args ) {
230             delete $args{$_} if !defined($args{$_}) || !length($args{$_});
231             }
232              
233             return %args;
234             }
235              
236             sub _verb {
237             my $self = $_[0];
238              
239             if ($self->identify) {
240             return 'Identify';
241             }
242             elsif ($self->listIdentifiers) {
243             return 'ListIdentifiers';
244             }
245             elsif ($self->listSets) {
246             return 'ListSets';
247             }
248             elsif ($self->getRecord) {
249             return 'GetRecord';
250             }
251             elsif ($self->listMetadataFormats) {
252             return 'ListMetadataFormats';
253             }
254             elsif ($self->listRecords) {
255             return 'ListRecords';
256             }
257             else {
258             return 'ListRecords';
259             }
260             }
261              
262             sub handle_record {
263             my ($self, $dom) = @_;
264             return unless $dom;
265              
266             $dom = $self->xslt->transform($dom) if $self->xslt;
267             return blessed($self->handler)
268             ? $self->handler->parse($dom)
269             : $self->handler->($dom);
270             }
271              
272             sub dry_run {
273             my ($self) = @_;
274             sub {
275             state $called = 0;
276             return if $called;
277             $called = 1;
278             # TODO: make sure that HTTP::OAI does not change this internal method
279             return +{
280             url => $self->oai->_buildurl(
281             $self->_args(),
282             verb => $self->_verb()
283             )
284             };
285             };
286             }
287              
288             sub _retry {
289             my ( $self, $sub ) = @_;
290              
291             $self->_retried( 0 );
292              
293             my $res;
294              
295             while ( 1 ) {
296              
297             $res = $sub->();
298              
299             if ($res->is_error && ref($res) ne 'HTTP::OAI::Response') {
300              
301             my $max_retries = $self->max_retries();
302             my $_retried = $self->_retried();
303              
304             if ( $max_retries > 0 && $_retried < $max_retries ){
305              
306             $_retried++;
307              
308             #exponential backoff: [0 .. 2^c [
309             my $n_seconds = int( 2**$_retried );
310             $self->log->error("failed, retrying after $n_seconds");
311             sleep $n_seconds;
312             $self->_retried( $_retried );
313             next;
314             }
315             else {
316             my $err_msg = $self->url . " : " . $res->message." (stopped after ".$self->_retried()." retries)";
317             $self->log->error( $err_msg );
318             Catmandu::Error->throw( $err_msg );
319             }
320             }
321              
322             last;
323             }
324              
325             $res;
326             }
327              
328             sub _list_records {
329             my $self = $_[0];
330             my $args = $_[1];
331             sub {
332             state $stack = [];
333             state $resumptionToken = $self->resumptionToken;
334             state $resumptionData = {};
335             state $done = 0;
336              
337             my $fill_stack = sub {
338             push @$stack , shift;
339             };
340              
341             if (@$stack <= 1 && $done == 0) {
342             my %args = $args ? %$args : $self->_args;
343              
344             # Use the resumptionToken if one found on the last run, or if it was
345             # undefined (last record)
346             if (defined $resumptionToken) {
347             my $verb = $args{verb};
348             %args = (verb => $verb , resumptionToken => $resumptionToken);
349             }
350              
351             my $sub = $self->listIdentifiers() ?
352             sub { $self->oai->ListIdentifiers( %args , onRecord => $fill_stack ); } :
353             sub { $self->oai->ListRecords( %args , onRecord => $fill_stack ); };
354              
355             my $res = $self->_retry( $sub );
356             if (defined $res->resumptionToken) {
357             $resumptionToken = $res->resumptionToken->resumptionToken;
358              
359             $resumptionData->{token} = $resumptionToken;
360             $resumptionData->{expirationDate} = $res->resumptionToken->expirationDate;
361             $resumptionData->{completeListSize} = $res->resumptionToken->completeListSize;
362             $resumptionData->{cursor} = $res->resumptionToken->cursor;
363             }
364             else {
365             $resumptionToken = undef;
366             }
367              
368             unless (defined $resumptionToken && length $resumptionToken) {
369             $done = 1;
370             }
371             }
372              
373             if (my $rec = shift @$stack) {
374             if ($rec->isa('HTTP::OAI::Record')) {
375             my $rec = $self->_map_record($rec);
376              
377             $rec->{_resumptionToken} = $resumptionToken if defined($resumptionToken);
378             $rec->{_resumption} = $resumptionData if defined($resumptionData);
379              
380             return $rec;
381             }
382             else {
383             my $rec = {
384             _id => $rec->identifier,
385             _datestamp => $rec->datestamp,
386             _status => $rec->status // "",
387             };
388              
389             $rec->{_resumptionToken} = $resumptionToken if defined($resumptionToken);
390             $rec->{_resumption} = $resumptionData if defined($resumptionData);
391              
392             return $rec;
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 $sub = sub { $self->oai->ListSets( onRecord => $fill_stack ); };
412              
413             my $res = $self->_retry( $sub );
414             $done = 1;
415             }
416              
417             if (my $rec = shift @$stack) {
418             return $self->_map_set($rec);
419             }
420              
421             return undef;
422             };
423             }
424              
425             sub _get_record {
426             my $self = $_[0];
427             my $args = $_[1];
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 = $args ? %$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             my $args = $_[1];
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 = $args ? %$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