File Coverage

blib/lib/Dancer/Plugin/Catmandu/OAI.pm
Criterion Covered Total %
statement 143 302 47.3
branch 32 156 20.5
condition 18 85 21.1
subroutine 15 23 65.2
pod n/a
total 208 566 36.7


line stmt bran cond sub pod time code
1             package Dancer::Plugin::Catmandu::OAI;
2              
3             =head1 NAME
4              
5             Dancer::Plugin::Catmandu::OAI - OAI-PMH provider backed by a searchable Catmandu::Store
6              
7             =cut
8              
9             our $VERSION = '0.0508';
10              
11 2     2   302850 use Catmandu::Sane;
  2         426308  
  2         13  
12 2     2   559 use Catmandu::Util qw(is_string is_array_ref hash_merge);
  2         5  
  2         115  
13 2     2   1491 use Catmandu;
  2         232326  
  2         11  
14 2     2   387 use Catmandu::Fix;
  2         5  
  2         39  
15 2     2   1000 use Catmandu::Exporter::Template;
  2         84362  
  2         65  
16 2     2   896 use Data::MessagePack;
  2         1958  
  2         59  
17 2     2   860 use MIME::Base64 qw(encode_base64url decode_base64url);
  2         1081  
  2         119  
18 2     2   861 use Dancer::Plugin;
  2         35897  
  2         133  
19 2     2   619 use Dancer qw(:syntax);
  2         137321  
  2         14  
20 2     2   2185 use DateTime;
  2         814522  
  2         99  
21 2     2   1383 use DateTime::Format::ISO8601;
  2         216831  
  2         185  
22 2     2   46 use DateTime::Format::Strptime;
  2         5  
  2         18  
23              
24             my $DEFAULT_LIMIT = 100;
25              
26             my $VERBS = {
27             GetRecord => {
28             valid => {metadataPrefix => 1, identifier => 1},
29             required => [qw(metadataPrefix identifier)],
30             },
31             Identify => {valid => {}, required => [],},
32             ListIdentifiers => {
33             valid => {
34             metadataPrefix => 1,
35             from => 1,
36             until => 1,
37             set => 1,
38             resumptionToken => 1
39             },
40             required => [qw(metadataPrefix)],
41             },
42             ListMetadataFormats =>
43             {valid => {identifier => 1, resumptionToken => 1}, required => [],},
44             ListRecords => {
45             valid => {
46             metadataPrefix => 1,
47             from => 1,
48             until => 1,
49             set => 1,
50             resumptionToken => 1
51             },
52             required => [qw(metadataPrefix)],
53             },
54             ListSets => {valid => {resumptionToken => 1}, required => [],},
55             };
56              
57             {
58             state $mp = Data::MessagePack->new->utf8;
59              
60             sub _deserialize {
61 0     0   0 $mp->unpack(decode_base64url($_[0]));
62             }
63              
64             sub _serialize {
65 0     0   0 encode_base64url($mp->pack($_[0]));
66             }
67              
68             }
69              
70             sub _new_token {
71 0     0   0 my ($settings, $hits, $params, $from, $until, $old_token) = @_;
72              
73 0 0 0     0 my $n = $old_token && $old_token->{_n} ? $old_token->{_n} : 0;
74 0         0 $n += $hits->size;
75              
76 0 0       0 return unless $n < $hits->total;
77              
78 0         0 my $strategy = $settings->{search_strategy};
79              
80 0         0 my $token;
81              
82 0 0 0     0 if ($strategy eq 'paginate' && $hits->more) {
    0 0        
83 0         0 $token = {start => $hits->start + $hits->limit};
84             }
85             elsif ($strategy eq 'es.scroll' && exists $hits->{scroll_id}) {
86 0         0 $token = {scroll_id => $hits->{scroll_id}};
87             }
88             else {
89 0         0 return;
90             }
91              
92 0         0 $token->{_n} = $n;
93 0 0       0 $token->{_s} = $params->{set} if defined $params->{set};
94             $token->{_m} = $params->{metadataPrefix}
95 0 0       0 if defined $params->{metadataPrefix};
96 0 0       0 $token->{_f} = $from if defined $from;
97 0 0       0 $token->{_u} = $until if defined $until;
98 0         0 $token;
99             }
100              
101             sub _search {
102 0     0   0 my ($settings, $bag, $q, $token) = @_;
103              
104 0         0 my $strategy = $settings->{search_strategy};
105              
106             my %args = (
107 0         0 %{$settings->{default_search_params}},
108 0   0     0 limit => $settings->{limit} // $DEFAULT_LIMIT,
109             cql_query => $q,
110             );
111 0 0       0 if ($token) {
112 0 0 0     0 if ($strategy eq 'paginate' && exists $token->{start}) {
    0 0        
113 0         0 $args{start} = $token->{start};
114             }
115             elsif ($strategy eq 'es.scroll' && exists $token->{scroll_id}) {
116 0         0 $args{scroll_id} = $token->{scroll_id};
117             }
118             }
119              
120 0         0 $bag->search(%args);
121             }
122              
123             sub oai_provider {
124 2     2   38205 my ($path, %opts) = @_;
125              
126 2         12 my $setting = hash_merge(plugin_setting, \%opts);
127              
128 2         130 my $bag = Catmandu->store($setting->{store})->bag($setting->{bag});
129              
130 2   50     118435 $setting->{granularity} //= "YYYY-MM-DDThh:mm:ssZ";
131              
132             # TODO this was for backwards compatibility. Remove?
133 2 50       8 if ($setting->{filter}) {
134 0         0 $setting->{cql_filter} = delete $setting->{filter};
135             }
136              
137 2   50     33 $setting->{default_search_params} //= {};
138              
139 2   50     10 $setting->{search_strategy} //= 'paginate';
140              
141             # TODO expire scroll_id if finished
142             # TODO set resumptionToken expirationDate
143 2 50       5 if ($setting->{search_strategy} eq 'es.scroll') {
144 0   0     0 $setting->{default_search_params}{scroll} //= '10m';
145             }
146              
147 2         3 my $datestamp_parser;
148 2 50       5 if ($setting->{datestamp_pattern}) {
149             $datestamp_parser = DateTime::Format::Strptime->new(
150             pattern => $setting->{datestamp_pattern},
151 0         0 on_error => 'undef',
152             );
153             }
154              
155             my $format_datestamp = $datestamp_parser
156             ? sub {
157 0     0   0 $datestamp_parser->parse_datetime($_[0])->iso8601 . 'Z';
158             }
159             : sub {
160 0     0   0 $_[0];
161 2 50       11 };
162              
163 2   33     11 $setting->{datestamp_index} //= $setting->{datestamp_field};
164              
165 2   33     37 $setting->{get_record_cql_pattern} ||= $bag->id_key . ' exact "%s"';
166              
167 2         99 my $metadata_formats = do {
168 2         4 my $list = $setting->{metadata_formats};
169 2         5 my $hash = {};
170 2         5 for my $format (@$list) {
171 4         10 my $prefix = $format->{metadataPrefix};
172 4         69 $format = {%$format};
173 4 50       15 if (my $fix = $format->{fix}) {
174 4         61 $format->{fix} = Catmandu::Fix->new(fixes => $fix);
175             }
176 4         1396 $hash->{$prefix} = $format;
177             }
178 2         5 $hash;
179             };
180              
181 2         3 my $sets = do {
182 2 50       7 if (my $list = $setting->{sets}) {
183 2         12 my $hash = {};
184 2         5 for my $set (@$list) {
185 6         9 my $key = $set->{setSpec};
186 6         14 $hash->{$key} = $set;
187             }
188 2         4 $hash;
189             }
190             else {
191 0         0 +{};
192             }
193             };
194              
195 2         7 my $ns = "oai:$setting->{repositoryIdentifier}:";
196              
197 2         3 my $branding = "";
198 2 50       8 if (my $icon = $setting->{collectionIcon}) {
199 0 0       0 if (my $url = $icon->{url}) {
200 0         0 $branding .= <<TT;
201             <description>
202             <branding xmlns="http://www.openarchives.org/OAI/2.0/branding/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/branding/ http://www.openarchives.org/OAI/2.0/branding.xsd">
203             <collectionIcon>
204             <url>$url</url>
205             TT
206 0         0 for my $tag (qw(link title width height)) {
207 0   0     0 my $val = $icon->{$tag} // next;
208 0         0 $branding .= "<$tag>$val</$tag>\n";
209             }
210              
211 0         0 $branding .= <<TT;
212             </collectionIcon>
213             </branding>
214             </description>
215             TT
216             }
217             }
218              
219 2         3 my $xsl_stylesheet = "";
220 2 50       6 if (my $xsl_path = $setting->{xsl_stylesheet}) {
221 0         0 $xsl_stylesheet
222             = "<?xml-stylesheet type='text/xsl' href='$xsl_path' ?>";
223             }
224              
225 2         5 my $template_header = <<TT;
226             <?xml version="1.0" encoding="UTF-8"?>
227             $xsl_stylesheet
228             <OAI-PMH xmlns="http://www.openarchives.org/OAI/2.0/"
229             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
230             xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/ http://www.openarchives.org/OAI/2.0/OAI-PMH.xsd">
231             <responseDate>[% response_date %]</responseDate>
232             [%- IF params.resumptionToken %]
233             <request verb="[% params.verb %]" resumptionToken="[% params.resumptionToken %]">[% uri_base %]</request>
234             [%- ELSE %]
235             <request[% FOREACH param IN params %] [% param.key %]="[% param.value | xml %]"[% END %]>[% uri_base %]</request>
236             [%- END %]
237             TT
238              
239 2         4 my $template_footer = <<TT;
240             </OAI-PMH>
241             TT
242              
243 2         5 my $template_error = <<TT;
244             $template_header
245             [%- FOREACH error IN errors %]
246             <error code="[% error.0 %]">[% error.1 | xml %]</error>
247             [%- END %]
248             $template_footer
249             TT
250              
251 2         4 my $template_record_header = <<TT;
252             <header[% IF deleted %] status="deleted"[% END %]>
253             <identifier>${ns}[% id %]</identifier>
254             <datestamp>[% datestamp %]</datestamp>
255             [%- FOREACH s IN setSpec %]
256             <setSpec>[% s %]</setSpec>
257             [%- END %]
258             </header>
259             TT
260              
261 2         13 my $template_get_record = <<TT;
262             $template_header
263             <GetRecord>
264             <record>
265             $template_record_header
266             [%- UNLESS deleted %]
267             <metadata>
268             [% metadata %]
269             </metadata>
270             [%- END %]
271             </record>
272             </GetRecord>
273             $template_footer
274             TT
275              
276 2   50     7 my $admin_email = $setting->{adminEmail} // [];
277 2 50       25 $admin_email = [$admin_email] unless is_array_ref($admin_email);
278             $admin_email
279 2         5 = join('', map {"<adminEmail>$_</adminEmail>"} @$admin_email);
  2         10  
280              
281 2         4 my @identify_extra_fields;
282 2         5 for my $i_field (qw(description compression)) {
283 4   50     21 my $i_value = $setting->{$i_field} // [];
284 4 50       14 $i_value = [$i_value] unless is_array_ref($i_value);
285             push @identify_extra_fields,
286 4         13 join('', map {"<$i_field>$_</$i_field>"} @$i_value);
  0         0  
287             }
288              
289 2         26 my $template_identify = <<TT;
290             $template_header
291             <Identify>
292             <repositoryName>$setting->{repositoryName}</repositoryName>
293             <baseURL>[% uri_base %]</baseURL>
294             <protocolVersion>2.0</protocolVersion>
295             $admin_email
296             <earliestDatestamp>[% earliest_datestamp %]</earliestDatestamp>
297             <deletedRecord>$setting->{deletedRecord}</deletedRecord>
298             <granularity>$setting->{granularity}</granularity>
299             <description>
300             <oai-identifier xmlns="http://www.openarchives.org/OAI/2.0/oai-identifier"
301             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
302             xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai-identifier http://www.openarchives.org/OAI/2.0/oai-identifier.xsd">
303             <scheme>oai</scheme>
304             <repositoryIdentifier>$setting->{repositoryIdentifier}</repositoryIdentifier>
305             <delimiter>$setting->{delimiter}</delimiter>
306             <sampleIdentifier>$setting->{sampleIdentifier}</sampleIdentifier>
307             </oai-identifier>
308             </description>
309             @identify_extra_fields
310             $branding
311             </Identify>
312             $template_footer
313             TT
314              
315 2         12 my $template_list_identifiers = <<TT;
316             $template_header
317             <ListIdentifiers>
318             [%- FOREACH records %]
319             $template_record_header
320             [%- END %]
321             [%- IF resumption_token %]
322             <resumptionToken completeListSize="[% total %]">[% resumption_token %]</resumptionToken>
323             [%- ELSE %]
324             <resumptionToken completeListSize="[% total %]"/>
325             [%- END %]
326             </ListIdentifiers>
327             $template_footer
328             TT
329              
330 2         12 my $template_list_records = <<TT;
331             $template_header
332             <ListRecords>
333             [%- FOREACH records %]
334             <record>
335             $template_record_header
336             [%- UNLESS deleted %]
337             <metadata>
338             [% metadata %]
339             </metadata>
340             [%- END %]
341             </record>
342             [%- END %]
343             [%- IF resumption_token %]
344             <resumptionToken completeListSize="[% total %]">[% resumption_token %]</resumptionToken>
345             [%- ELSE %]
346             <resumptionToken completeListSize="[% total %]"/>
347             [%- END %]
348             </ListRecords>
349             $template_footer
350             TT
351              
352 2         4 my $template_list_metadata_formats = "";
353 2         10 $template_list_metadata_formats .= <<TT;
354             $template_header
355             <ListMetadataFormats>
356             TT
357 2         7 for my $format (values %$metadata_formats) {
358 4         13 $template_list_metadata_formats .= <<TT;
359             <metadataFormat>
360             <metadataPrefix>$format->{metadataPrefix}</metadataPrefix>
361             <schema>$format->{schema}</schema>
362             <metadataNamespace>$format->{metadataNamespace}</metadataNamespace>
363             </metadataFormat>
364             TT
365             }
366 2         5 $template_list_metadata_formats .= <<TT;
367             </ListMetadataFormats>
368             $template_footer
369             TT
370              
371 2         10 my $template_list_sets = <<TT;
372             $template_header
373             <ListSets>
374             TT
375 2         5 for my $set (values %$sets) {
376 6         16 $template_list_sets .= <<TT;
377             <set>
378             <setSpec>$set->{setSpec}</setSpec>
379             <setName>$set->{setName}</setName>
380             TT
381              
382 6   50     18 my $set_descriptions = $set->{setDescription} // [];
383 6 50       14 $set_descriptions = [$set_descriptions]
384             unless is_array_ref($set_descriptions);
385             $template_list_sets .= "<setDescription>$_</setDescription>"
386 6         11 for @$set_descriptions;
387              
388 6         8 $template_list_sets .= <<TT;
389             </set>
390             TT
391             }
392 2         5 $template_list_sets .= <<TT;
393             </ListSets>
394             $template_footer
395             TT
396              
397 2         4 my $fix = $setting->{fix};
398 2 50       3 if ($fix) {
399 0         0 $fix = Catmandu::Fix->new(fixes => $fix);
400             }
401 2   50 0   14 my $sub_deleted = $opts{deleted} || sub {0};
  0            
402 2   50 0   18 my $sub_set_specs_for = $opts{set_specs_for} || sub {[]};
  0            
403              
404 2   50     19 my $template_options = $setting->{template_options} || {};
405              
406             my $render = sub {
407 15     15   33 my ($tmpl, $data) = @_;
408 15         50 content_type 'xml';
409 15         1914 my $out = "";
410 15         353 my $exporter = Catmandu::Exporter::Template->new(
411             template => $tmpl,
412             file => \$out
413             );
414 15         5279 $exporter->add($data);
415 15         189028 $exporter->commit;
416 15         515 $out;
417 2         15 };
418              
419             any ['get', 'post'] => $path => sub {
420             my $uri_base = $setting->{uri_base}
421 15   33 15   39828 // request->uri_for(request->path_info);
422 15         95 my $response_date = DateTime->now->iso8601 . 'Z';
423 15 100       4759 my $params = request->is_get ? params('query') : params('body');
424 15         1719 my $errors = [];
425 15         34 my $format;
426             my $set;
427 15         31 my $verb = $params->{verb};
428 15         82 my $vars = {
429             uri_base => $uri_base,
430             request_uri => $uri_base . $path,
431             response_date => $response_date,
432             errors => $errors,
433             };
434              
435 15 100 66     79 if ($verb and my $spec = $VERBS->{$verb}) {
436 7         16 my $valid = $spec->{valid};
437 7         14 my $required = $spec->{required};
438              
439 7 50 66     26 if ($valid->{resumptionToken}
440             and exists $params->{resumptionToken})
441             {
442 0 0       0 if (keys(%$params) > 2) {
443 0         0 push @$errors,
444             [badArgument =>
445             "resumptionToken cannot be combined with other parameters"
446             ];
447             }
448             }
449             else {
450 7         22 for my $key (keys %$params) {
451 7 50       18 next if $key eq 'verb';
452 0 0       0 unless ($valid->{$key}) {
453 0         0 push @$errors,
454             [badArgument => "parameter $key is illegal"];
455             }
456             }
457 7         16 for my $key (@$required) {
458 0 0       0 unless (exists $params->{$key}) {
459 0         0 push @$errors,
460             [badArgument => "parameter $key is missing"];
461             }
462             }
463             }
464             }
465             else {
466 8         31 push @$errors, [badVerb => "illegal OAI verb"];
467             }
468              
469 15 100       36 if (@$errors) {
470 8         24 return $render->(\$template_error, $vars);
471             }
472              
473 7         13 $vars->{params} = $params;
474              
475 7 50       18 if (exists $params->{resumptionToken}) {
476 0 0       0 unless (is_string($params->{resumptionToken})) {
477 0         0 push @$errors,
478             [badResumptionToken =>
479             "resumptionToken is not in the correct format"
480             ];
481             }
482              
483 0 0       0 if ($verb eq 'ListSets') {
484 0         0 push @$errors,
485             [badResumptionToken => "resumptionToken isn't necessary"];
486             }
487             else {
488             try {
489 0         0 my $token = _deserialize($params->{resumptionToken});
490 0 0       0 $params->{set} = $token->{_s} if defined $token->{_s};
491             $params->{metadataPrefix} = $token->{_m}
492 0 0       0 if defined $token->{_m};
493 0 0       0 $params->{from} = $token->{_f} if defined $token->{_f};
494 0 0       0 $params->{until} = $token->{_u} if defined $token->{_u};
495 0         0 $vars->{token} = $token;
496             }
497             catch {
498 0         0 push @$errors,
499             [badResumptionToken =>
500             "resumptionToken is not in the correct format"
501             ];
502 0         0 };
503              
504             }
505             }
506              
507 7 50       25 if (exists $params->{set}) {
508 0 0       0 unless ($sets) {
509 0         0 push @$errors, [noSetHierarchy => "sets are not supported"];
510             }
511 0 0       0 unless ($set = $sets->{$params->{set}}) {
512 0         0 push @$errors, [badArgument => "set does not exist"];
513             }
514             }
515              
516 7 50       43 if (exists $params->{metadataPrefix}) {
517 0 0       0 unless ($format = $metadata_formats->{$params->{metadataPrefix}})
518             {
519 0         0 push @$errors,
520             [cannotDisseminateFormat =>
521             "metadataPrefix $params->{metadataPrefix} is not supported"
522             ];
523             }
524             }
525              
526 7 50       15 if (@$errors) {
527 0         0 return $render->(\$template_error, $vars);
528             }
529              
530 7 50 33     43 if ($verb eq 'GetRecord') {
    100          
    50          
    100          
    50          
531 0         0 my $id = $params->{identifier};
532 0         0 $id =~ s/^$ns//;
533              
534             my $rec = $bag->search(
535 0         0 %{$setting->{default_search_params}},
536 0         0 cql_query => sprintf($setting->{get_record_cql_pattern}, $id),
537             start => 0,
538             limit => 1,
539             )->first;
540              
541 0 0       0 if (defined $rec) {
542 0 0       0 if ($fix) {
543 0         0 $rec = $fix->fix($rec);
544             }
545              
546 0         0 $vars->{id} = $id;
547             $vars->{datestamp} = $format_datestamp->(
548 0         0 $rec->{$setting->{datestamp_field}});
549 0         0 $vars->{deleted} = $sub_deleted->($rec);
550 0         0 $vars->{setSpec} = $sub_set_specs_for->($rec);
551 0         0 my $metadata = "";
552             my $exporter = Catmandu::Exporter::Template->new(
553             %$template_options,
554             template => $format->{template},
555 0         0 file => \$metadata,
556             );
557 0 0       0 if ($format->{fix}) {
558 0         0 $rec = $format->{fix}->fix($rec);
559             }
560 0         0 $exporter->add($rec);
561 0         0 $exporter->commit;
562 0         0 $vars->{metadata} = $metadata;
563 0 0 0     0 unless ($vars->{deleted}
564             and $setting->{deletedRecord} eq 'no')
565             {
566 0         0 return $render->(\$template_get_record, $vars);
567             }
568             }
569 0         0 push @$errors,
570             [idDoesNotExist =>
571             "identifier $params->{identifier} is unknown or illegal"
572             ];
573 0         0 return $render->(\$template_error, $vars);
574              
575             }
576             elsif ($verb eq 'Identify') {
577             $vars->{earliest_datestamp}
578 3   33     12 = $setting->{earliestDatestamp} || do {
579             my $hits = $bag->search(
580             %{$setting->{default_search_params}},
581             cql_query => $setting->{cql_filter} || 'cql.allRecords',
582             limit => 1,
583             sru_sortkeys => "$setting->{datestamp_index},,1",
584             );
585             if (my $rec = $hits->first) {
586             $format_datestamp->($rec->{$setting->{datestamp_field}});
587             }
588             else {
589             '1970-01-01T00:00:01Z';
590             }
591             };
592 3         9 return $render->(\$template_identify, $vars);
593              
594             }
595             elsif ($verb eq 'ListIdentifiers' || $verb eq 'ListRecords') {
596 0         0 my $from = $params->{from};
597 0         0 my $until = $params->{until};
598              
599 0         0 for my $datestamp (($from, $until)) {
600 0 0       0 $datestamp || next;
601 0 0       0 if ($datestamp
602             !~ /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}Z)?$/)
603             {
604 0         0 push @$errors,
605             [badArgument =>
606             "datestamps must have the format YYYY-MM-DD or YYYY-MM-DDThh:mm:ssZ"
607             ];
608 0         0 return $render->(\$template_error, $vars);
609             }
610             }
611              
612 0 0 0     0 if ($from && $until && length($from) != length($until)) {
      0        
613 0         0 push @$errors,
614             [
615             badArgument => "datestamps must have the same granularity"
616             ];
617 0         0 return $render->(\$template_error, $vars);
618             }
619              
620 0 0 0     0 if ($from && $until && $from gt $until) {
      0        
621 0         0 push @$errors,
622             [badArgument => "from is more recent than until"];
623 0         0 return $render->(\$template_error, $vars);
624             }
625              
626 0 0 0     0 if ($from && length($from) == 10) {
627 0         0 $from = "${from}T00:00:00Z";
628             }
629 0 0 0     0 if ($until && length($until) == 10) {
630 0         0 $until = "${until}T23:59:59Z";
631             }
632              
633 0         0 my @cql;
634 0         0 my $cql_from = $from;
635 0         0 my $cql_until = $until;
636 0 0       0 if (my $pattern = $setting->{datestamp_pattern}) {
637 0 0       0 $cql_from
638             = DateTime::Format::ISO8601->parse_datetime($from)
639             ->strftime($pattern)
640             if $cql_from;
641 0 0       0 $cql_until
642             = DateTime::Format::ISO8601->parse_datetime($until)
643             ->strftime($pattern)
644             if $cql_until;
645             }
646              
647 0 0       0 push @cql, qq|($setting->{cql_filter})| if $setting->{cql_filter};
648 0 0       0 push @cql, qq|($format->{cql})| if $format->{cql};
649 0 0 0     0 push @cql, qq|($set->{cql})| if $set && $set->{cql};
650 0 0       0 push @cql, qq|($setting->{datestamp_index} >= "$cql_from")|
651             if $cql_from;
652 0 0       0 push @cql, qq|($setting->{datestamp_index} <= "$cql_until")|
653             if $cql_until;
654 0 0       0 unless (@cql) {
655 0         0 push @cql, "(cql.allRecords)";
656             }
657              
658             my $search = _search($setting, $bag, join(' and ', @cql),
659 0         0 $vars->{token});
660              
661 0 0       0 unless ($search->total) {
662 0         0 push @$errors, [noRecordsMatch => "no records found"];
663 0         0 return $render->(\$template_error, $vars);
664             }
665              
666 0 0       0 if (
667             defined(
668             my $new_token = _new_token(
669             $setting, $search, $params,
670             $from, $until, $vars->{token}
671             )
672             )
673             )
674             {
675 0         0 $vars->{resumption_token} = _serialize($new_token);
676             }
677              
678 0         0 $vars->{total} = $search->total;
679              
680 0 0       0 if ($verb eq 'ListIdentifiers') {
681             $vars->{records} = [
682             map {
683 0         0 my $rec = $_;
684 0         0 my $id = $rec->{$bag->id_key};
685              
686 0 0       0 if ($fix) {
687 0         0 $rec = $fix->fix($rec);
688             }
689              
690             {
691             id => $id,
692             datestamp => $format_datestamp->(
693             $rec->{$setting->{datestamp_field}}
694 0         0 ),
695             deleted => $sub_deleted->($rec),
696             setSpec => $sub_set_specs_for->($rec),
697             };
698 0         0 } @{$search->hits}
  0         0  
699             ];
700 0         0 return $render->(\$template_list_identifiers, $vars);
701             }
702             else {
703             $vars->{records} = [
704             map {
705 0         0 my $rec = $_;
706 0         0 my $id = $rec->{$bag->id_key};
707              
708 0 0       0 if ($fix) {
709 0         0 $rec = $fix->fix($rec);
710             }
711              
712 0         0 my $deleted = $sub_deleted->($rec);
713              
714             my $rec_vars = {
715             id => $id,
716             datestamp => $format_datestamp->(
717             $rec->{$setting->{datestamp_field}}
718 0         0 ),
719             deleted => $deleted,
720             setSpec => $sub_set_specs_for->($rec),
721             };
722 0 0       0 unless ($deleted) {
723 0         0 my $metadata = "";
724             my $exporter = Catmandu::Exporter::Template->new(
725             %$template_options,
726             template => $format->{template},
727 0         0 file => \$metadata,
728             );
729 0 0       0 if ($format->{fix}) {
730 0         0 $rec = $format->{fix}->fix($rec);
731             }
732 0         0 $exporter->add($rec);
733 0         0 $exporter->commit;
734 0         0 $rec_vars->{metadata} = $metadata;
735             }
736 0         0 $rec_vars;
737 0         0 } @{$search->hits}
  0         0  
738             ];
739 0         0 return $render->(\$template_list_records, $vars);
740             }
741              
742             }
743             elsif ($verb eq 'ListMetadataFormats') {
744 2 50       7 if (my $id = $params->{identifier}) {
745 0         0 $id =~ s/^$ns//;
746 0 0       0 unless ($bag->get($id)) {
747 0         0 push @$errors,
748             [idDoesNotExist =>
749             "identifier $params->{identifier} is unknown or illegal"
750             ];
751 0         0 return $render->(\$template_error, $vars);
752             }
753             }
754 2         6 return $render->(\$template_list_metadata_formats, $vars);
755              
756             }
757             elsif ($verb eq 'ListSets') {
758 2         7 return $render->(\$template_list_sets, $vars);
759             }
760             }
761 2         47 }
762              
763             register oai_provider => \&oai_provider;
764              
765             register_plugin;
766              
767             1;
768              
769             =head1 SYNOPSIS
770              
771             #!/usr/bin/env perl
772              
773             use Dancer;
774             use Catmandu;
775             use Dancer::Plugin::Catmandu::OAI;
776              
777             Catmandu->load;
778             Catmandu->config;
779              
780             my $options = {};
781              
782             oai_provider '/oai' , %$options;
783              
784             dance;
785              
786             =head1 DESCRIPTION
787              
788             L<Dancer::Plugin::Catmandu::OAI> is a Dancer plugin to provide OAI-PMH services for L<Catmandu::Store>-s that support
789             CQL (such as L<Catmandu::Store::ElasticSearch>). Follow the installation steps below to setup your own OAI-PMH server.
790              
791             =head1 REQUIREMENTS
792              
793             In the examples below an ElasticSearch 1.7.2 L<https://www.elastic.co/downloads/past-releases/elasticsearch-1-7-2> server
794             will be used.
795              
796             Follow the instructions below for a demonstration installation:
797              
798             $ cpanm Dancer Catmandu::OAI Catmandu::Store::ElasticSearch
799              
800             $ wget https://download.elastic.co/elasticsearch/elasticsearch/elasticsearch-1.7.2.zip
801             $ unzip elasticsearch-1.7.2.zip
802             $ cd elasticsearch-1.7.2
803             $ bin/elasticsearch
804              
805             =head1 RECORDS
806              
807             Records stored in the Catmandu::Store can be in any format. Preferably the format should be easy to convert into the
808             mandatory OAI-DC format. At a minimum each record contains an identifier '_id' and a field containing a datestamp.
809              
810             $ cat sample.yml
811             ---
812             _id: oai:my.server.org:123456
813             datestamp: 2016-05-17T13:37:18Z
814             creator:
815             - Musterman, Max
816             - Jansen, Jan
817             - Svenson, Sven
818             title:
819             - Test record
820             ...
821              
822             =head1 CATMANDU CONFIGURATION
823              
824             ElasticSearch requires a configuration file to map record fields to CQL terms. Below is a minimal configuration required to query
825             for identifiers and datastamps in the ElasticSearch collection:
826              
827             $ cat catmandu.yml
828             ---
829             store:
830             oai:
831             package: ElasticSearch
832             options:
833             index_name: oai
834             bags:
835             data:
836             cql_mapping:
837             default_index: basic
838             indexes:
839             _id:
840             op:
841             'any': true
842             'all': true
843             '=': true
844             'exact': true
845             field: '_id'
846             datestamp:
847             op:
848             '=': true
849             '<': true
850             '<=': true
851             '>=': true
852             '>': true
853             'exact': true
854             field: 'datestamp'
855             index_mappings:
856             publication:
857             properties:
858             datestamp: {type: date, format: date_time_no_millis}
859              
860             =head1 IMPORT RECORDS
861              
862             With the Catmandu configuration files in place records can be imported with the L<catmandu> command:
863              
864             # Drop the existing ElasticSearch 'oai' collection
865             $ catmandu drop oai
866              
867             # Import the sample record
868             $ catmandu import YAML to oai < sample.yml
869              
870             # Test if the records are available in the 'oai' collection
871             $ catmandu export oai
872              
873             =head1 DANCER CONFIGURATION
874              
875             The Dancer configuration file 'config.yml' contains basic information for the OAI-PMH plugin to work:
876              
877             * store - In which Catmandu::Store are the metadata records stored
878             * bag - In which Catmandu::Bag are the records of this 'store' (use: 'data' as default)
879             * datestamp_field - Which field in the record contains a datestamp ('datestamp' in our example above)
880             * datestamp_index - Which CQL index should be used to find records within a specified date range (if not specified, the value from the 'datestamp_field' setting is used)
881             * repositoryName - The name of the repository
882             * uri_base - The full base url of the OAI controller. To be used when behind a proxy server. When not set, this module relies on the Dancer request to provide its full url. Use middleware like 'ReverseProxy' or 'Dancer::Middleware::Rebase' in that case.
883             * adminEmail - An administrative email. Can be string or array of strings. This will be included in the Identify response.
884             * compression - a compression encoding supported by the repository. Can be string or array of strings. This will be included in the Identify response.
885             * description - XML container that describes your repository. Can be string or array of strings. This will be included in the Identify response. Note that this module will try to validate the XML data.
886             * earliestDatestamp - The earliest datestamp available in the dataset as YYYY-MM-DDTHH:MM:SSZ. This will be determined dynamically if no static value is given.
887             * deletedRecord - The policy for deleted records. See also: L<https://www.openarchives.org/OAI/openarchivesprotocol.html#DeletedRecords>
888             * repositoryIdentifier - A prefix to use in OAI-PMH identifiers
889             * cql_filter - A CQL query to find all records in the database that should be made available to OAI-PMH
890             * default_search_params - set default arguments that get passed to every call to the bag's search method
891             * search_strategy - default is C<paginate>, set to C<es.scroll> to avoid deep paging (Elasticsearch only)
892             * limit - The maximum number of records to be returned in each OAI-PMH request
893             * delimiter - Delimiters used in prefixing a record identifier with a repositoryIdentifier (use: ':' as default)
894             * sampleIdentifier - A sample identifier
895             * metadata_formats - An array of metadataFormats that are supported
896             * metadataPrefix - A short string for the name of the format
897             * schema - An URL to the XSD schema of this format
898             * metadataNamespace - A XML namespace for this format
899             * template - The path to a Template Toolkit file to transform your records into this format
900             * fix - Optionally an array of one or more L<Catmandu::Fix>-es or Fix files
901             * sets - Optional an array of OAI-PMH sets and the CQL query to retrieve records in this set from the Catmandu::Store
902             * setSpec - A short string for the same of the set
903             * setName - A longer description of the set
904             * setDescription - an optional and repeatable container that may hold community-specific XML-encoded data about the set. Should be string or array of strings.
905             * cql - The CQL command to find records in this set in the L<Catmandu::Store>
906             * xsl_stylesheet - Optional path to an xsl stylesheet
907             * template_options - An optional hash of configuration options that will be passed to L<Catmandu::Exporter::Template> or L<Template>.
908              
909             Below is a sample minimal configuration for the 'sample.yml' demo above:
910              
911             $ cat config.yml
912             charset: "UTF-8"
913             plugins:
914             'Catmandu::OAI':
915             store: oai
916             bag: data
917             datestamp_field: datestamp
918             repositoryName: "My OAI DataProvider"
919             uri_base: "http://oai.service.com/oai"
920             adminEmail: me@example.com
921             earliestDatestamp: "1970-01-01T00:00:01Z"
922             cql_filter: "datestamp>1970-01-01T00:00:01Z"
923             deletedRecord: persistent
924             repositoryIdentifier: oai.service.com
925             limit: 200
926             delimiter: ":"
927             sampleIdentifier: "oai:oai.service.com:1585315"
928             metadata_formats:
929             -
930             metadataPrefix: oai_dc
931             schema: "http://www.openarchives.org/OAI/2.0/oai_dc.xsd"
932             metadataNamespace: "http://www.openarchives.org/OAI/2.0/oai_dc/"
933             template: oai_dc.tt
934              
935             =head1 METADATAPREFIX TEMPLATE
936              
937             For each metadataPrefix a Template Toolkit file needs to exist which translate L<Catmandu::Store> records into XML records. At least
938             one Template Toolkit file should be made available to transform stored records into Dublin Core. The example below contains an example file to
939             transform 'sample.yml' type records into Dublin Core:
940              
941             $ cat oai_dc.tt
942             <oai_dc:dc xmlns="http://www.openarchives.org/OAI/2.0/oai_dc/"
943             xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/"
944             xmlns:dc="http://purl.org/dc/elements/1.1/"
945             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
946             xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd">
947             [%- FOREACH var IN ['title' 'creator' 'subject' 'description' 'publisher' 'contributor' 'date' 'type' 'format' 'identifier' 'source' 'language' 'relation' 'coverage' 'rights'] %]
948             [%- FOREACH val IN $var %]
949             <dc:[% var %]>[% val | html %]</dc:[% var %]>
950             [%- END %]
951             [%- END %]
952             </oai_dc:dc>
953              
954             =head1 START DANCER
955              
956             If all the required files are available, then a Dancer application can be started. See the 'demo' directory of this distribution for a complete example:
957              
958             $ ls
959             app.pl catmandu.yml config.yml oai_dc.tt
960             $ cat app.pl
961             #!/usr/bin/env perl
962              
963             use Dancer;
964             use Catmandu;
965             use Dancer::Plugin::Catmandu::OAI;
966              
967             Catmandu->load;
968             Catmandu->config;
969              
970             my $options = {};
971              
972             oai_provider '/oai' , %$options;
973              
974             dance;
975              
976             # Start Dancer
977             $ perl ./app.pl
978              
979             # Test queries:
980              
981             $ curl "http://localhost:3000/oai?verb=Identify"
982             $ curl "http://localhost:3000/oai?verb=ListSets"
983             $ curl "http://localhost:3000/oai?verb=ListMetadataFormats"
984             $ curl "http://localhost:3000/oai?verb=ListIdentifiers&metadataPrefix=oai_dc"
985             $ curl "http://localhost:3000/oai?verb=ListRecords&metadataPrefix=oai_dc"
986              
987             =head1 SEE ALSO
988              
989             L<Dancer>, L<Catmandu>, L<Catmandu::Store>
990              
991             =head1 AUTHOR
992              
993             Nicolas Steenlant, C<< <nicolas.steenlant at ugent.be> >>
994              
995             =head1 CONTRIBUTORS
996              
997             Nicolas Franck, C<< <nicolas.franck at ugent.be> >>
998              
999             Vitali Peil, C<< <vitali.peil at uni-bielefeld.de> >>
1000              
1001             Patrick Hochstenbach, C<< <patric.hochstenbach at ugent.be> >>
1002              
1003             =head1 LICENSE
1004              
1005             This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.
1006              
1007             =cut