Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
5.79% |
27 / 466 |
|
13.89% |
5 / 36 |
CRAP | |
0.00% |
0 / 1 |
Server | |
5.79% |
27 / 466 |
|
13.89% |
5 / 36 |
23204.38 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
init | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
setRecordLinkerHelper | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setRecordFormatter | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getUTCDateTime | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getResponse | |
13.33% |
2 / 15 |
|
0.00% |
0 / 1 |
61.73 | |||
attachDeleted | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
attachRecordHeader | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
getVuFindMetadata | |
0.00% |
0 / 36 |
|
0.00% |
0 / 1 |
2 | |||
attachNonDeleted | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
182 | |||
getRecord | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
56 | |||
hasParam | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
identify | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
6 | |||
supportsVuFindMetadata | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
initializeMetadataFormats | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
getMetadataFormats | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
initializeSettings | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
132 | |||
listMetadataFormats | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
110 | |||
listRecords | |
0.00% |
0 / 60 |
|
0.00% |
0 / 1 |
306 | |||
listSets | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
132 | |||
listRecordsGetDeleted | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
listRecordsGetDeletedCount | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
listRecordsGetNonDeleted | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
56 | |||
listRecordsGetParams | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
306 | |||
isBadDate | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
132 | |||
dateTimeCreationSuccessful | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
listRecordsValidateDates | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
30 | |||
loadRecord | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
loadResumptionToken | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
normalizeDate | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
prefixID | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
saveResumptionToken | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
showError | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
createResponse | |
86.67% |
13 / 15 |
|
0.00% |
0 / 1 |
3.02 | |||
stripID | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
unexpectedError | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | /** |
4 | * OAI Server class |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) Villanova University 2010. |
9 | * Copyright (C) The National Library of Finland 2018-2019. |
10 | * |
11 | * This program is free software; you can redistribute it and/or modify |
12 | * it under the terms of the GNU General Public License version 2, |
13 | * as published by the Free Software Foundation. |
14 | * |
15 | * This program is distributed in the hope that it will be useful, |
16 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
17 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
18 | * GNU General Public License for more details. |
19 | * |
20 | * You should have received a copy of the GNU General Public License |
21 | * along with this program; if not, write to the Free Software |
22 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
23 | * |
24 | * @category VuFind |
25 | * @package OAI_Server |
26 | * @author Demian Katz <demian.katz@villanova.edu> |
27 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
28 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
29 | * @link https://vufind.org/wiki/development Wiki |
30 | */ |
31 | |
32 | namespace VuFind\OAI; |
33 | |
34 | use SimpleXMLElement; |
35 | use VuFind\Db\Entity\ChangeTrackerEntityInterface; |
36 | use VuFind\Db\Service\ChangeTrackerServiceInterface; |
37 | use VuFind\Db\Service\OaiResumptionServiceInterface; |
38 | use VuFind\Exception\RecordMissing as RecordMissingException; |
39 | use VuFind\SimpleXML; |
40 | use VuFindApi\Formatter\RecordFormatter; |
41 | |
42 | use function count; |
43 | use function in_array; |
44 | use function intval; |
45 | use function strlen; |
46 | |
47 | /** |
48 | * OAI Server class |
49 | * |
50 | * This class provides OAI server functionality. |
51 | * |
52 | * @category VuFind |
53 | * @package OAI_Server |
54 | * @author Demian Katz <demian.katz@villanova.edu> |
55 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
56 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
57 | * @link https://vufind.org/wiki/development Wiki |
58 | */ |
59 | class Server |
60 | { |
61 | /** |
62 | * Repository base URL |
63 | * |
64 | * @var string |
65 | */ |
66 | protected $baseURL; |
67 | |
68 | /** |
69 | * Base URL of host containing VuFind. |
70 | * |
71 | * @var string |
72 | */ |
73 | protected $baseHostURL; |
74 | |
75 | /** |
76 | * Incoming request parameters |
77 | * |
78 | * @var array |
79 | */ |
80 | protected $params; |
81 | |
82 | /** |
83 | * Search object class to use |
84 | * |
85 | * @var string |
86 | */ |
87 | protected $searchClassId = 'Solr'; |
88 | |
89 | /** |
90 | * What Solr core are we serving up? |
91 | * |
92 | * @var string |
93 | */ |
94 | protected $core = 'biblio'; |
95 | |
96 | /** |
97 | * ISO-8601 date format |
98 | * |
99 | * @var string |
100 | */ |
101 | protected $iso8601 = 'Y-m-d\TH:i:s\Z'; |
102 | |
103 | /** |
104 | * Records per page in lists |
105 | * |
106 | * @var int |
107 | */ |
108 | protected $pageSize = 100; |
109 | |
110 | /** |
111 | * Solr field for set membership |
112 | * |
113 | * @var string |
114 | */ |
115 | protected $setField = null; |
116 | |
117 | /** |
118 | * Supported metadata formats |
119 | * |
120 | * @var array |
121 | */ |
122 | protected $metadataFormats = []; |
123 | |
124 | /** |
125 | * Namespace used for ID prefixing (if any) |
126 | * |
127 | * @var string |
128 | */ |
129 | protected $idNamespace = null; |
130 | |
131 | /** |
132 | * Repository name used in "Identify" response |
133 | * |
134 | * @var string |
135 | */ |
136 | protected $repositoryName = 'VuFind'; |
137 | |
138 | /** |
139 | * Earliest datestamp used in "Identify" response |
140 | * |
141 | * @var string |
142 | */ |
143 | protected $earliestDatestamp = '2000-01-01T00:00:00Z'; |
144 | |
145 | /** |
146 | * Admin email used in "Identify" response |
147 | * |
148 | * @var string |
149 | */ |
150 | protected $adminEmail; |
151 | |
152 | /** |
153 | * Record link helper (optional) |
154 | * |
155 | * @var \VuFind\View\Helper\Root\RecordLinker |
156 | */ |
157 | protected $recordLinkerHelper = null; |
158 | |
159 | /** |
160 | * Set queries |
161 | * |
162 | * @var array |
163 | */ |
164 | protected $setQueries = []; |
165 | |
166 | /* |
167 | * Default query used when a set is not specified |
168 | * |
169 | * @var string |
170 | */ |
171 | protected $defaultQuery = ''; |
172 | |
173 | /* |
174 | * Record formatter |
175 | * |
176 | * @var RecordFormatter |
177 | */ |
178 | protected $recordFormatter = null; |
179 | |
180 | /** |
181 | * Fields to return when the 'vufind' format is requested. Empty array means the |
182 | * format is disabled. |
183 | * |
184 | * @var array |
185 | */ |
186 | protected $vufindApiFields = []; |
187 | |
188 | /** |
189 | * Filter queries specific to the requested record format |
190 | * |
191 | * @var array |
192 | */ |
193 | protected $recordFormatFilters = []; |
194 | |
195 | /** |
196 | * Limit on display of deleted records (in days); older deleted records will not |
197 | * be returned by the server. Set to null for no limit. |
198 | * |
199 | * @var int |
200 | */ |
201 | protected $deleteLifetime = null; |
202 | |
203 | /** |
204 | * Should we use cursorMarks for Solr retrieval? Normally this is the best |
205 | * option, but it is incompatible with some other Solr features and may need |
206 | * to be disabled in rare circumstances (e.g. when using field collapsing/ |
207 | * result grouping). |
208 | * |
209 | * @var bool |
210 | */ |
211 | protected $useCursorMark = true; |
212 | |
213 | /** |
214 | * Constructor |
215 | * |
216 | * @param \VuFind\Search\Results\PluginManager $resultsManager Search manager for retrieving records |
217 | * @param \VuFind\Record\Loader $recordLoader Record loader |
218 | * @param ChangeTrackerServiceInterface $trackerService ChangeTracker Service |
219 | * @param OaiResumptionServiceInterface $resumptionService Database service for resumption tokens |
220 | */ |
221 | public function __construct( |
222 | protected \VuFind\Search\Results\PluginManager $resultsManager, |
223 | protected \VuFind\Record\Loader $recordLoader, |
224 | protected ChangeTrackerServiceInterface $trackerService, |
225 | protected OaiResumptionServiceInterface $resumptionService |
226 | ) { |
227 | } |
228 | |
229 | /** |
230 | * Initialize settings |
231 | * |
232 | * @param \Laminas\Config\Config $config VuFind configuration |
233 | * @param string $baseURL The base URL for the OAI server |
234 | * @param array $params The incoming OAI-PMH parameters (i.e. |
235 | * $_GET) |
236 | * |
237 | * @return void |
238 | */ |
239 | public function init(\Laminas\Config\Config $config, $baseURL, array $params) |
240 | { |
241 | $this->baseURL = $baseURL; |
242 | $parts = parse_url($baseURL); |
243 | $this->baseHostURL = $parts['scheme'] . '://' . $parts['host']; |
244 | if (isset($parts['port'])) { |
245 | $this->baseHostURL .= $parts['port']; |
246 | } |
247 | $this->params = $params; |
248 | $this->initializeSettings($config); // Load config.ini settings |
249 | } |
250 | |
251 | /** |
252 | * Add a record linker helper (optional -- allows enhancement of some metadata |
253 | * with VuFind-specific links). |
254 | * |
255 | * @param \VuFind\View\Helper\Root\RecordLinker $helper Helper to set |
256 | * |
257 | * @return void |
258 | */ |
259 | public function setRecordLinkerHelper($helper) |
260 | { |
261 | $this->recordLinkerHelper = $helper; |
262 | } |
263 | |
264 | /** |
265 | * Add a record formatter (optional -- allows the vufind record format to be |
266 | * returned). |
267 | * |
268 | * @param RecordFormatter $formatter Record formatter |
269 | * |
270 | * @return void |
271 | */ |
272 | public function setRecordFormatter($formatter) |
273 | { |
274 | $this->recordFormatter = $formatter; |
275 | // Reset metadata formats so they can be reinitialized; the formatter |
276 | // may enable additional options. |
277 | $this->metadataFormats = []; |
278 | } |
279 | |
280 | /** |
281 | * Get the current UTC date/time in ISO 8601 format. |
282 | * |
283 | * @param string $time Time string to represent as UTC (default = 'now') |
284 | * |
285 | * @return string |
286 | */ |
287 | protected function getUTCDateTime($time = 'now') |
288 | { |
289 | // All times must be in UTC, so translate the current time to the |
290 | // appropriate time zone: |
291 | $utc = new \DateTime($time, new \DateTimeZone('UTC')); |
292 | return date_format($utc, $this->iso8601); |
293 | } |
294 | |
295 | /** |
296 | * Respond to the OAI-PMH request. |
297 | * |
298 | * @return string |
299 | */ |
300 | public function getResponse() |
301 | { |
302 | if (!$this->hasParam('verb')) { |
303 | return $this->showError('badVerb', 'Missing Verb Argument'); |
304 | } else { |
305 | switch ($this->params['verb']) { |
306 | case 'GetRecord': |
307 | return $this->getRecord(); |
308 | case 'Identify': |
309 | return $this->identify(); |
310 | case 'ListIdentifiers': |
311 | case 'ListRecords': |
312 | return $this->listRecords($this->params['verb']); |
313 | case 'ListMetadataFormats': |
314 | return $this->listMetadataFormats(); |
315 | case 'ListSets': |
316 | return $this->listSets(); |
317 | default: |
318 | return $this->showError('badVerb', 'Illegal OAI Verb'); |
319 | } |
320 | } |
321 | } |
322 | |
323 | /** |
324 | * Assign necessary interface variables to display a deleted record. |
325 | * |
326 | * @param SimpleXMLElement $xml XML to update |
327 | * @param ChangeTrackerEntityInterface $trackerEntity ChangeTracker entity |
328 | * @param bool $headerOnly Only attach the header? |
329 | * |
330 | * @return void |
331 | */ |
332 | protected function attachDeleted($xml, $trackerEntity, $headerOnly = false) |
333 | { |
334 | // Deleted records only have a header, no metadata. However, depending |
335 | // on the context we are attaching them, they may or may not need a |
336 | // <record> tag wrapping the header. |
337 | $record = $headerOnly ? $xml : $xml->addChild('record'); |
338 | $this->attachRecordHeader( |
339 | $record, |
340 | $this->prefixID($trackerEntity->getId()), |
341 | date($this->iso8601, $trackerEntity->getDeleted()->getTimestamp()), |
342 | [], |
343 | 'deleted' |
344 | ); |
345 | } |
346 | |
347 | /** |
348 | * Attach a record header to an XML document. |
349 | * |
350 | * @param SimpleXMLElement $xml XML to update |
351 | * @param string $id Record id |
352 | * @param string $date Record modification date |
353 | * @param array $sets Set(s) containing record |
354 | * @param string $status Record status code |
355 | * |
356 | * @return void |
357 | */ |
358 | protected function attachRecordHeader( |
359 | $xml, |
360 | $id, |
361 | $date, |
362 | $sets = [], |
363 | $status = '' |
364 | ) { |
365 | $header = $xml->addChild('header'); |
366 | if (!empty($status)) { |
367 | $header['status'] = $status; |
368 | } |
369 | $header->identifier = $id; |
370 | $header->datestamp = $date; |
371 | foreach ($sets as $set) { |
372 | $header->addChild('setSpec', htmlspecialchars($set)); |
373 | } |
374 | } |
375 | |
376 | /** |
377 | * Support method for attachNonDeleted() to build the VuFind metadata for |
378 | * a record driver. |
379 | * |
380 | * @param object $record A record driver object |
381 | * |
382 | * @return string |
383 | */ |
384 | protected function getVuFindMetadata($record) |
385 | { |
386 | // Root node |
387 | $recordDoc = new \DOMDocument(); |
388 | $vufindFormat = $this->getMetadataFormats()['oai_vufind_json']; |
389 | $rootNode = $recordDoc->createElementNS( |
390 | $vufindFormat['namespace'], |
391 | 'oai_vufind_json:record' |
392 | ); |
393 | $rootNode->setAttribute( |
394 | 'xmlns:xsi', |
395 | 'http://www.w3.org/2001/XMLSchema-instance' |
396 | ); |
397 | $rootNode->setAttribute( |
398 | 'xsi:schemaLocation', |
399 | $vufindFormat['namespace'] . ' ' . $vufindFormat['schema'] |
400 | ); |
401 | $recordDoc->appendChild($rootNode); |
402 | |
403 | // Add oai_dc part |
404 | $oaiDc = new \DOMDocument(); |
405 | $oaiDc->loadXML( |
406 | $record->getXML('oai_dc', $this->baseHostURL, $this->recordLinkerHelper) |
407 | ); |
408 | $rootNode->appendChild( |
409 | $recordDoc->importNode($oaiDc->documentElement, true) |
410 | ); |
411 | |
412 | // Add VuFind metadata |
413 | $records = $this->recordFormatter->format( |
414 | [$record], |
415 | $this->vufindApiFields |
416 | ); |
417 | $metadataNode = $recordDoc->createElementNS( |
418 | $vufindFormat['namespace'], |
419 | 'oai_vufind_json:metadata' |
420 | ); |
421 | $metadataNode->setAttribute('type', 'application/json'); |
422 | $metadataNode->appendChild( |
423 | $recordDoc->createCDATASection(json_encode($records[0])) |
424 | ); |
425 | $rootNode->appendChild($metadataNode); |
426 | |
427 | return $recordDoc->saveXML(); |
428 | } |
429 | |
430 | /** |
431 | * Attach a non-deleted record to an XML document. |
432 | * |
433 | * @param SimpleXMLElement $container XML container for new record |
434 | * @param object $record A record driver object |
435 | * @param string $format Metadata format to obtain (false for none) |
436 | * @param bool $headerOnly Only attach the header? |
437 | * @param string $set Currently active set |
438 | * |
439 | * @return bool |
440 | */ |
441 | protected function attachNonDeleted( |
442 | $container, |
443 | $record, |
444 | $format, |
445 | $headerOnly = false, |
446 | $set = '' |
447 | ) { |
448 | // Get the XML (and display an error if it is unsupported): |
449 | if ($format === false) { |
450 | $xml = ''; // no metadata if in header-only mode! |
451 | } elseif ('oai_vufind_json' === $format && $this->supportsVuFindMetadata()) { |
452 | $xml = $this->getVuFindMetadata($record); // special case |
453 | } else { |
454 | $xml = $record |
455 | ->getXML($format, $this->baseHostURL, $this->recordLinkerHelper); |
456 | if ($xml === false) { |
457 | return false; |
458 | } |
459 | } |
460 | |
461 | // Headers should be returned only if the metadata format matching |
462 | // the supplied metadataPrefix is available. |
463 | // If RecordDriver returns nothing, skip this record. |
464 | if (empty($xml)) { |
465 | return true; |
466 | } |
467 | |
468 | // Check for sets: |
469 | $fields = $record->getRawData(); |
470 | if (null !== $this->setField && !empty($fields[$this->setField])) { |
471 | $sets = (array)$fields[$this->setField]; |
472 | } else { |
473 | $sets = []; |
474 | } |
475 | if (!empty($set)) { |
476 | $sets = array_unique(array_merge($sets, [$set])); |
477 | } |
478 | |
479 | // Get modification date: |
480 | $date = $record->getLastIndexed(); |
481 | if (empty($date)) { |
482 | $date = $this->getUTCDateTime('now'); |
483 | } |
484 | |
485 | // Set up header (inside or outside a <record> container depending on |
486 | // preferences): |
487 | $recXml = $headerOnly ? $container : $container->addChild('record'); |
488 | $this->attachRecordHeader( |
489 | $recXml, |
490 | $this->prefixID($record->getUniqueID()), |
491 | $date, |
492 | $sets |
493 | ); |
494 | |
495 | // Inject metadata if necessary: |
496 | if (!$headerOnly && !empty($xml)) { |
497 | $metadata = $recXml->addChild('metadata'); |
498 | SimpleXML::appendElement($metadata, $xml); |
499 | } |
500 | |
501 | return true; |
502 | } |
503 | |
504 | /** |
505 | * Respond to a GetRecord request. |
506 | * |
507 | * @return string |
508 | */ |
509 | protected function getRecord() |
510 | { |
511 | // Validate parameters |
512 | if (!$this->hasParam('metadataPrefix')) { |
513 | return $this->showError('badArgument', 'Missing Metadata Prefix'); |
514 | } |
515 | if (!$this->hasParam('identifier')) { |
516 | return $this->showError('badArgument', 'Missing Identifier'); |
517 | } |
518 | |
519 | // Start building response |
520 | $response = $this->createResponse(); |
521 | $xml = $response->addChild('GetRecord'); |
522 | |
523 | // Retrieve the record from the index |
524 | if ($record = $this->loadRecord($this->params['identifier'])) { |
525 | $success = $this->attachNonDeleted( |
526 | $xml, |
527 | $record, |
528 | $this->params['metadataPrefix'] |
529 | ); |
530 | if (!$success) { |
531 | return $this->showError('cannotDisseminateFormat', 'Unknown Format'); |
532 | } |
533 | } else { |
534 | // No record in index -- is this deleted? |
535 | |
536 | $row = $this->trackerService->getChangeTrackerEntity( |
537 | $this->core, |
538 | $this->stripID($this->params['identifier']) |
539 | ); |
540 | if (!empty($row) && !empty($row->getDeleted())) { |
541 | $this->attachDeleted($xml, $row); |
542 | } else { |
543 | // Not deleted and not found in index -- error! |
544 | return $this->showError('idDoesNotExist', 'Unknown Record'); |
545 | } |
546 | } |
547 | |
548 | // Display the record: |
549 | return $response->asXML(); |
550 | } |
551 | |
552 | /** |
553 | * Was the specified parameter provided? |
554 | * |
555 | * @param string $param Name of the parameter to check. |
556 | * |
557 | * @return bool True if parameter is set and non-empty. |
558 | */ |
559 | protected function hasParam($param) |
560 | { |
561 | return isset($this->params[$param]) && !empty($this->params[$param]); |
562 | } |
563 | |
564 | /** |
565 | * Respond to an Identify request: |
566 | * |
567 | * @return string |
568 | */ |
569 | protected function identify() |
570 | { |
571 | $response = $this->createResponse(); |
572 | $xml = $response->addChild('Identify'); |
573 | $xml->repositoryName = $this->repositoryName; |
574 | $xml->baseURL = $this->baseURL; |
575 | $xml->protocolVersion = '2.0'; |
576 | $xml->adminEmail = $this->adminEmail; |
577 | $xml->earliestDatestamp = $this->earliestDatestamp; |
578 | $xml->deletedRecord = 'transient'; |
579 | $xml->granularity = 'YYYY-MM-DDThh:mm:ssZ'; |
580 | if (!empty($this->idNamespace)) { |
581 | $id = $xml->addChild('description')->addChild( |
582 | 'oai-identifier', |
583 | null, |
584 | 'http://www.openarchives.org/OAI/2.0/oai-identifier' |
585 | ); |
586 | $id->addAttribute( |
587 | 'xsi:schemaLocation', |
588 | 'http://www.openarchives.org/OAI/2.0/oai-identifier ' |
589 | . 'http://www.openarchives.org/OAI/2.0/oai-identifier.xsd', |
590 | 'http://www.w3.org/2001/XMLSchema-instance' |
591 | ); |
592 | $id->scheme = 'oai'; |
593 | $id->repositoryIdentifier = $this->idNamespace; |
594 | $id->delimiter = ':'; |
595 | $id->sampleIdentifier = 'oai:' . $this->idNamespace . ':123456'; |
596 | } |
597 | |
598 | return $response->asXML(); |
599 | } |
600 | |
601 | /** |
602 | * Does the current configuration support the VuFind metadata format (using |
603 | * the API's record formatter. |
604 | * |
605 | * @return bool |
606 | */ |
607 | protected function supportsVuFindMetadata() |
608 | { |
609 | return !empty($this->vufindApiFields) && null !== $this->recordFormatter; |
610 | } |
611 | |
612 | /** |
613 | * Initialize data about metadata formats. (This is called on demand and is |
614 | * defined as a separate method to allow easy override by child classes). |
615 | * |
616 | * @return void |
617 | */ |
618 | protected function initializeMetadataFormats() |
619 | { |
620 | $this->metadataFormats['oai_dc'] = [ |
621 | 'schema' => 'http://www.openarchives.org/OAI/2.0/oai_dc.xsd', |
622 | 'namespace' => 'http://www.openarchives.org/OAI/2.0/oai_dc/']; |
623 | $this->metadataFormats['marc21'] = [ |
624 | 'schema' => 'http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd', |
625 | 'namespace' => 'http://www.loc.gov/MARC21/slim']; |
626 | |
627 | if ($this->supportsVuFindMetadata()) { |
628 | $this->metadataFormats['oai_vufind_json'] = [ |
629 | 'schema' => 'https://vufind.org/xsd/oai_vufind_json-1.0.xsd', |
630 | 'namespace' => 'http://vufind.org/oai_vufind_json-1.0', |
631 | ]; |
632 | } else { |
633 | unset($this->metadataFormats['oai_vufind_json']); |
634 | } |
635 | } |
636 | |
637 | /** |
638 | * Get metadata formats; initialize the list if necessary. |
639 | * |
640 | * @return array |
641 | */ |
642 | protected function getMetadataFormats() |
643 | { |
644 | if (empty($this->metadataFormats)) { |
645 | $this->initializeMetadataFormats(); |
646 | } |
647 | return $this->metadataFormats; |
648 | } |
649 | |
650 | /** |
651 | * Load data from the OAI section of config.ini. (This is called by the |
652 | * constructor and is only a separate method to allow easy override by child |
653 | * classes). |
654 | * |
655 | * @param \Laminas\Config\Config $config VuFind configuration |
656 | * |
657 | * @return void |
658 | */ |
659 | protected function initializeSettings(\Laminas\Config\Config $config) |
660 | { |
661 | // Override default repository name if configured: |
662 | if (isset($config->OAI->repository_name)) { |
663 | $this->repositoryName = $config->OAI->repository_name; |
664 | } |
665 | |
666 | // Override default ID namespace if configured: |
667 | if (isset($config->OAI->identifier)) { |
668 | $this->idNamespace = $config->OAI->identifier; |
669 | } |
670 | |
671 | // Override page size if configured: |
672 | if (isset($config->OAI->page_size)) { |
673 | $this->pageSize = $config->OAI->page_size; |
674 | } |
675 | |
676 | // Use either OAI-specific or general email address; we must have SOMETHING. |
677 | $this->adminEmail = $config->OAI->admin_email ?? $config->Site->email; |
678 | |
679 | // Use a Solr field to determine sets, if configured: |
680 | if (isset($config->OAI->set_field)) { |
681 | $this->setField = $config->OAI->set_field; |
682 | } |
683 | |
684 | // Initialize custom sets queries: |
685 | if (isset($config->OAI->set_query)) { |
686 | $this->setQueries = $config->OAI->set_query->toArray(); |
687 | } |
688 | |
689 | // Use a default query, if configured: |
690 | if (isset($config->OAI->default_query)) { |
691 | $this->defaultQuery = $config->OAI->default_query; |
692 | } |
693 | |
694 | // Initialize VuFind API format fields: |
695 | $this->vufindApiFields = array_filter( |
696 | explode( |
697 | ',', |
698 | $config->OAI->vufind_api_format_fields ?? '' |
699 | ) |
700 | ); |
701 | |
702 | // Initialize filters specific to requested metadataPrefix: |
703 | if (isset($config->OAI->record_format_filters)) { |
704 | $this->recordFormatFilters |
705 | = $config->OAI->record_format_filters->toArray(); |
706 | } |
707 | |
708 | // Initialize delete lifetime, if set: |
709 | if (isset($config->OAI->delete_lifetime)) { |
710 | $this->deleteLifetime = intval($config->OAI->delete_lifetime); |
711 | } |
712 | |
713 | // Change cursormark behavior if necessary: |
714 | $cursor = $config->OAI->use_cursor ?? true; |
715 | if (!$cursor || strtolower($cursor) === 'false') { |
716 | $this->useCursorMark = false; |
717 | } |
718 | } |
719 | |
720 | /** |
721 | * Respond to a ListMetadataFormats request. |
722 | * |
723 | * @return string |
724 | */ |
725 | protected function listMetadataFormats() |
726 | { |
727 | // If a specific ID was provided, try to load the related record; otherwise, |
728 | // set $record to false so we know it is a generic request. |
729 | if (isset($this->params['identifier'])) { |
730 | if (!($record = $this->loadRecord($this->params['identifier']))) { |
731 | return $this->showError('idDoesNotExist', 'Unknown Record'); |
732 | } |
733 | } else { |
734 | $record = false; |
735 | } |
736 | |
737 | // Loop through all available metadata formats and see if they apply in |
738 | // the current context (all apply if $record is false, since that |
739 | // means that no specific record ID was requested; otherwise, they only |
740 | // apply if the current record driver supports them): |
741 | $response = $this->createResponse(); |
742 | $xml = $response->addChild('ListMetadataFormats'); |
743 | foreach ($this->getMetadataFormats() as $prefix => $details) { |
744 | if ( |
745 | $record === false |
746 | || $record->getXML($prefix) !== false |
747 | || ('oai_vufind_json' === $prefix && $this->supportsVuFindMetadata()) |
748 | ) { |
749 | $node = $xml->addChild('metadataFormat'); |
750 | $node->metadataPrefix = $prefix; |
751 | if (isset($details['schema'])) { |
752 | $node->schema = $details['schema']; |
753 | } |
754 | if (isset($details['namespace'])) { |
755 | $node->metadataNamespace = $details['namespace']; |
756 | } |
757 | } |
758 | } |
759 | |
760 | // Display the response: |
761 | return $response->asXML(); |
762 | } |
763 | |
764 | /** |
765 | * Respond to a ListIdentifiers or ListRecords request (the $verb parameter |
766 | * determines the exact format of the response). |
767 | * |
768 | * @param string $verb 'ListIdentifiers' or 'ListRecords' |
769 | * |
770 | * @return string |
771 | */ |
772 | protected function listRecords($verb = 'ListRecords') |
773 | { |
774 | // Load and validate parameters; if an Exception is thrown, we need to parse |
775 | // and output an appropriate error. |
776 | try { |
777 | $params = $this->listRecordsGetParams(); |
778 | } catch (\Exception $e) { |
779 | $parts = explode(':', $e->getMessage(), 2); |
780 | if (count($parts) != 2) { |
781 | throw $e; |
782 | } |
783 | return $this->showError($parts[0], $parts[1]); |
784 | } |
785 | |
786 | // Normalize the provided dates into Unix timestamps. Depending on whether |
787 | // they come from the OAI-PMH request or the database, the format may be |
788 | // slightly different; this ensures they are reduced to a consistent value! |
789 | $from = $this->normalizeDate($params['from']); |
790 | $until = $this->normalizeDate($params['until'], '23:59:59'); |
791 | if (!$this->listRecordsValidateDates($from, $until)) { |
792 | return; |
793 | } |
794 | |
795 | // Copy the cursor from the parameters so we can track our current position |
796 | // separately from our initial position! |
797 | $currentCursor = $params['cursor']; |
798 | |
799 | $response = $this->createResponse(); |
800 | $xml = $response->addChild($verb); |
801 | |
802 | // The verb determines whether we're returning headers only or full records: |
803 | $headersOnly = ($verb != 'ListRecords'); |
804 | |
805 | // Apply the delete lifetime limit to the from date if necessary: |
806 | $deleteCutoff = $this->deleteLifetime |
807 | ? strtotime('-' . $this->deleteLifetime . ' days') : 0; |
808 | $deleteFrom = ($deleteCutoff < $from) ? $from : $deleteCutoff; |
809 | |
810 | // Get deleted records in the requested range (if applicable): |
811 | $deletedCount = $this->listRecordsGetDeletedCount($deleteFrom, $until); |
812 | if ($deletedCount > 0 && $currentCursor < $deletedCount) { |
813 | $deleted = $this |
814 | ->listRecordsGetDeleted($deleteFrom, $until, $currentCursor); |
815 | foreach ($deleted as $current) { |
816 | $this->attachDeleted($xml, $current, $headersOnly); |
817 | $currentCursor++; |
818 | } |
819 | } |
820 | |
821 | // Figure out how many non-deleted records we need to display: |
822 | $recordLimit = ($params['cursor'] + $this->pageSize) - $currentCursor; |
823 | // Depending on cursormark mode, we either need to get the latest mark or |
824 | // else calculate a Solr offset. |
825 | if ($this->useCursorMark) { |
826 | $offset = $cursorMark = $params['cursorMark'] ?? ''; |
827 | } else { |
828 | $cursorMark = ''; // always empty for checks below |
829 | $offset = ($currentCursor >= $deletedCount) |
830 | ? $currentCursor - $deletedCount : 0; |
831 | } |
832 | $format = $params['metadataPrefix']; |
833 | |
834 | // Get non-deleted records from the Solr index: |
835 | $set = $params['set'] ?? ''; |
836 | $result = $this->listRecordsGetNonDeleted( |
837 | $from, |
838 | $until, |
839 | $offset, |
840 | $recordLimit, |
841 | $format, |
842 | $set |
843 | ); |
844 | $nonDeletedCount = $result->getResultTotal(); |
845 | foreach ($result->getResults() as $doc) { |
846 | $this->attachNonDeleted($xml, $doc, $format, $headersOnly, $set); |
847 | $currentCursor++; |
848 | } |
849 | // We only need a cursor mark if we fetched results from Solr; if our |
850 | // $recordLimit is 0, it means that we're still in the process of |
851 | // retrieving deleted records, and we're only hitting Solr to obtain a |
852 | // total record count. Therefore, we don't want to change the cursor |
853 | // mark yet, or it will break pagination of deleted records. |
854 | $nextCursorMark = $recordLimit > 0 ? $result->getCursorMark() : ''; |
855 | |
856 | // If our cursor didn't reach the last record, we need a resumption token! |
857 | $listSize = $deletedCount + $nonDeletedCount; |
858 | if ( |
859 | $listSize > $currentCursor |
860 | && ('' === $cursorMark || $nextCursorMark !== $cursorMark) |
861 | ) { |
862 | $this->saveResumptionToken( |
863 | $xml, |
864 | $params, |
865 | $currentCursor, |
866 | $listSize, |
867 | $nextCursorMark |
868 | ); |
869 | } elseif ($params['cursor'] > 0) { |
870 | // If we reached the end of the list but there is more than one page, we |
871 | // still need to display an empty <resumptionToken> tag: |
872 | $token = $xml->addChild('resumptionToken'); |
873 | $token->addAttribute('completeListSize', $listSize); |
874 | $token->addAttribute('cursor', $params['cursor']); |
875 | } |
876 | |
877 | return $response->asXML(); |
878 | } |
879 | |
880 | /** |
881 | * Respond to a ListSets request. |
882 | * |
883 | * @return string |
884 | */ |
885 | protected function listSets() |
886 | { |
887 | // Resumption tokens are not currently supported for this verb: |
888 | if ($this->hasParam('resumptionToken')) { |
889 | return $this->showError( |
890 | 'badResumptionToken', |
891 | 'Invalid resumption token' |
892 | ); |
893 | } |
894 | |
895 | // If no set field is enabled, we can't provide a set list: |
896 | if (null === $this->setField && empty($this->setQueries)) { |
897 | return $this->showError('noSetHierarchy', 'Sets not supported'); |
898 | } |
899 | |
900 | // Begin building XML: |
901 | $response = $this->createResponse(); |
902 | $xml = $response->addChild('ListSets'); |
903 | |
904 | // Load set field if applicable: |
905 | if (null !== $this->setField) { |
906 | // If we got this far, we can load all available set values. For now, |
907 | // we'll assume that this list is short enough to load in one response; |
908 | // it may be necessary to implement a resumption token mechanism if this |
909 | // proves not to be the case: |
910 | $results = $this->resultsManager->get($this->searchClassId); |
911 | try { |
912 | $facets = $results->getFullFieldFacets([$this->setField]); |
913 | } catch (\Exception $e) { |
914 | $facets = null; |
915 | } |
916 | if (empty($facets) || !isset($facets[$this->setField]['data']['list'])) { |
917 | $this->unexpectedError('Cannot find sets'); |
918 | } |
919 | |
920 | // Extract facet values from the Solr response: |
921 | foreach ($facets[$this->setField]['data']['list'] as $x) { |
922 | $set = $xml->addChild('set'); |
923 | $set->setSpec = $x['value']; |
924 | $set->setName = $x['displayText']; |
925 | } |
926 | } |
927 | |
928 | // Iterate over custom sets: |
929 | if (!empty($this->setQueries)) { |
930 | foreach ($this->setQueries as $setName => $solrQuery) { |
931 | $set = $xml->addChild('set'); |
932 | $set->setName = $set->setSpec = $setName; |
933 | $set->setDescription = $solrQuery; |
934 | } |
935 | } |
936 | |
937 | // Display the list: |
938 | return $response->asXML(); |
939 | } |
940 | |
941 | /** |
942 | * Get an object containing the next page of deleted records from the specified |
943 | * date range. |
944 | * |
945 | * @param int $from Start date. |
946 | * @param int $until End date. |
947 | * @param int $currentCursor Offset into result set |
948 | * |
949 | * @return ChangeTrackerEntityInterface[] |
950 | */ |
951 | protected function listRecordsGetDeleted($from, $until, $currentCursor) |
952 | { |
953 | return $this->trackerService->getDeletedEntities( |
954 | $this->core, |
955 | \DateTime::createFromFormat('U', $from), |
956 | \DateTime::createFromFormat('U', $until), |
957 | $currentCursor, |
958 | $this->pageSize |
959 | ); |
960 | } |
961 | |
962 | /** |
963 | * Get a count of all deleted records in the specified date range. |
964 | * |
965 | * @param int $from Start date. |
966 | * @param int $until End date. |
967 | * |
968 | * @return int |
969 | */ |
970 | protected function listRecordsGetDeletedCount($from, $until) |
971 | { |
972 | return $this->trackerService->getDeletedCount( |
973 | $this->core, |
974 | \DateTime::createFromFormat('U', $from), |
975 | \DateTime::createFromFormat('U', $until) |
976 | ); |
977 | } |
978 | |
979 | /** |
980 | * Get an array of information on non-deleted records in the specified range. |
981 | * |
982 | * @param int $from Start date. |
983 | * @param int $until End date. |
984 | * @param mixed $offset Solr offset, or cursorMark for the position in the full |
985 | * result list (depending on settings). |
986 | * @param int $limit Max number of full records to return. |
987 | * @param string $format Requested record format |
988 | * @param string $set Set to limit to (empty string for none). |
989 | * |
990 | * @return \VuFind\Search\Base\Results Search result object. |
991 | */ |
992 | protected function listRecordsGetNonDeleted( |
993 | $from, |
994 | $until, |
995 | $offset, |
996 | $limit, |
997 | $format, |
998 | $set = '' |
999 | ) { |
1000 | // Set up search parameters: |
1001 | $results = $this->resultsManager->get($this->searchClassId); |
1002 | $params = $results->getParams(); |
1003 | $params->setLimit($limit); |
1004 | $params->getOptions()->disableHighlighting(); |
1005 | $params->getOptions()->spellcheckEnabled(false); |
1006 | $params->setSort('last_indexed asc, id asc', true); |
1007 | |
1008 | // Construct a range query based on last indexed time: |
1009 | $params->setOverrideQuery( |
1010 | 'last_indexed:[' . date($this->iso8601, $from) . ' TO ' |
1011 | . date($this->iso8601, $until) . ']' |
1012 | ); |
1013 | |
1014 | // Apply filters as needed. |
1015 | if (!empty($set)) { |
1016 | if (isset($this->setQueries[$set])) { |
1017 | // Put parentheses around the query so that it does not get |
1018 | // parsed as a simple field:value filter. |
1019 | $params->addFilter('(' . $this->setQueries[$set] . ')'); |
1020 | } elseif (null !== $this->setField) { |
1021 | $params->addFilter( |
1022 | $this->setField . ':"' . addcslashes($set, '"') . '"' |
1023 | ); |
1024 | } |
1025 | } elseif ($this->defaultQuery) { |
1026 | // Put parentheses around the query so that it does not get |
1027 | // parsed as a simple field:value filter. |
1028 | $params->addFilter('(' . $this->defaultQuery . ')'); |
1029 | } |
1030 | |
1031 | if (!empty($this->recordFormatFilters[$format])) { |
1032 | $params->addFilter($this->recordFormatFilters[$format]); |
1033 | } |
1034 | |
1035 | // Perform a Solr search: |
1036 | if ($this->useCursorMark) { |
1037 | $results->overrideStartRecord(1); |
1038 | $results->setCursorMark($offset); |
1039 | } else { |
1040 | $results->overrideStartRecord($offset + 1); |
1041 | } |
1042 | |
1043 | // Return our results: |
1044 | return $results; |
1045 | } |
1046 | |
1047 | /** |
1048 | * Get parameters for use in the listRecords method. |
1049 | * |
1050 | * @throws \Exception |
1051 | * @return mixed Array of parameters or false on error |
1052 | */ |
1053 | protected function listRecordsGetParams() |
1054 | { |
1055 | // If we received a resumption token, use it to override any existing |
1056 | // parameters or fail if it is invalid. |
1057 | if (!empty($this->params['resumptionToken'])) { |
1058 | $params = $this->loadResumptionToken($this->params['resumptionToken']); |
1059 | if ($params === false) { |
1060 | throw new \Exception( |
1061 | 'badResumptionToken:Invalid or expired resumption token' |
1062 | ); |
1063 | } |
1064 | |
1065 | // Merge restored parameters with incoming parameters: |
1066 | $params = array_merge($params, $this->params); |
1067 | } else { |
1068 | // No resumption token? Use the provided parameters: |
1069 | $params = $this->params; |
1070 | |
1071 | // Make sure we don't act on any user-provided cursor settings; this |
1072 | // value should only be set in association with resumption tokens! |
1073 | $params['cursor'] = 0; |
1074 | |
1075 | // Set default date range if not already provided: |
1076 | if (empty($params['from'])) { |
1077 | $params['from'] = $this->earliestDatestamp; |
1078 | if ( |
1079 | !empty($params['until']) |
1080 | && strlen($params['from']) > strlen($params['until']) |
1081 | ) { |
1082 | $params['from'] = substr($params['from'], 0, 10); |
1083 | } |
1084 | } |
1085 | if (empty($params['until'])) { |
1086 | $params['until'] = $this->getUTCDateTime('now +1 day'); |
1087 | if (strlen($params['until']) > strlen($params['from'])) { |
1088 | $params['until'] = substr($params['until'], 0, 10); |
1089 | } |
1090 | } |
1091 | if ($this->isBadDate($params['from'], $params['until'])) { |
1092 | throw new \Exception('badArgument:Bad Date Format'); |
1093 | } |
1094 | } |
1095 | |
1096 | // If no set field is configured and a set parameter comes in, we have a |
1097 | // problem: |
1098 | if ( |
1099 | null === $this->setField && empty($this->setQueries) |
1100 | && !empty($params['set']) |
1101 | ) { |
1102 | throw new \Exception('noSetHierarchy:Sets not supported'); |
1103 | } |
1104 | |
1105 | // Validate set parameter: |
1106 | if ( |
1107 | !empty($params['set']) && null === $this->setField |
1108 | && !isset($this->setQueries[$params['set']]) |
1109 | ) { |
1110 | throw new \Exception('badArgument:Invalid set specified'); |
1111 | } |
1112 | |
1113 | if (!isset($params['metadataPrefix'])) { |
1114 | throw new \Exception('badArgument:Missing metadataPrefix'); |
1115 | } |
1116 | |
1117 | // Validate requested metadata format: |
1118 | $prefixes = array_keys($this->getMetadataFormats()); |
1119 | if (!in_array($params['metadataPrefix'], $prefixes)) { |
1120 | throw new \Exception('cannotDisseminateFormat:Unknown Format'); |
1121 | } |
1122 | |
1123 | return $params; |
1124 | } |
1125 | |
1126 | /** |
1127 | * Validate the from and until parameters for the listRecords method. |
1128 | * |
1129 | * @param int $from String for start date. |
1130 | * @param int $until String for end date. |
1131 | * |
1132 | * @return bool True if invalid, false if not. |
1133 | */ |
1134 | protected function isBadDate($from, $until) |
1135 | { |
1136 | $dt = \DateTime::createFromFormat('Y-m-d', substr($until, 0, 10)); |
1137 | if (!$this->dateTimeCreationSuccessful($dt)) { |
1138 | return true; |
1139 | } |
1140 | $dt = \DateTime::createFromFormat('Y-m-d', substr($from, 0, 10)); |
1141 | if (!$this->dateTimeCreationSuccessful($dt)) { |
1142 | return true; |
1143 | } |
1144 | // Check for different date granularity |
1145 | if (strpos($from, 'T') && strpos($from, 'Z')) { |
1146 | if (strpos($until, 'T') && strpos($until, 'Z')) { |
1147 | // This is good |
1148 | } else { |
1149 | return true; |
1150 | } |
1151 | } elseif (strpos($until, 'T') && strpos($until, 'Z')) { |
1152 | return true; |
1153 | } |
1154 | |
1155 | $from_time = $this->normalizeDate($from); |
1156 | $until_time = $this->normalizeDate($until, '23:59:59'); |
1157 | if ($from_time > $until_time) { |
1158 | throw new \Exception('noRecordsMatch:from vs. until'); |
1159 | } |
1160 | if ($from_time < $this->normalizeDate($this->earliestDatestamp)) { |
1161 | return true; |
1162 | } |
1163 | return false; |
1164 | } |
1165 | |
1166 | /** |
1167 | * Check if a DateTime was successfully created without errors or warnings |
1168 | * |
1169 | * @param \DateTime|false $dt DateTime or false (return value of createFromFormat) |
1170 | * |
1171 | * @return bool |
1172 | */ |
1173 | protected function dateTimeCreationSuccessful(\DateTime|false $dt): bool |
1174 | { |
1175 | // Return value false is always an error: |
1176 | if (false === $dt) { |
1177 | return false; |
1178 | } |
1179 | $errors = $dt->getLastErrors(); |
1180 | // getLastErrors returns false if no errors on PHP 8.2 and later: |
1181 | if (false === $errors) { |
1182 | return true; |
1183 | } |
1184 | // getLastErrors returns an array with no errors on PHP 8.1: |
1185 | return empty($errors['errors']) && empty($errors['warnings']); |
1186 | } |
1187 | |
1188 | /** |
1189 | * Validate the from and until parameters for the listRecords method. |
1190 | * |
1191 | * @param int $from Timestamp for start date. |
1192 | * @param int $until Timestamp for end date. |
1193 | * |
1194 | * @return bool True if valid, false if not. |
1195 | */ |
1196 | protected function listRecordsValidateDates($from, $until) |
1197 | { |
1198 | // Validate dates: |
1199 | if (!$from || !$until) { |
1200 | return $this->showError('badArgument', 'Bad Date Format'); |
1201 | } |
1202 | if ($from > $until) { |
1203 | return $this->showError( |
1204 | 'badArgument', |
1205 | 'End date must be after start date' |
1206 | ); |
1207 | } |
1208 | if ($from < $this->normalizeDate($this->earliestDatestamp)) { |
1209 | return $this->showError( |
1210 | 'badArgument', |
1211 | 'Start date must be after earliest date' |
1212 | ); |
1213 | } |
1214 | |
1215 | // If we got this far, everything is valid! |
1216 | return true; |
1217 | } |
1218 | |
1219 | /** |
1220 | * Load a specific record from the index. |
1221 | * |
1222 | * @param string $id The record ID to load |
1223 | * |
1224 | * @return mixed The record array (if successful) or false |
1225 | */ |
1226 | protected function loadRecord($id) |
1227 | { |
1228 | // Strip the ID prefix, if necessary: |
1229 | $id = $this->stripID($id); |
1230 | if ($id !== false) { |
1231 | try { |
1232 | return $this->recordLoader->load($id, $this->searchClassId); |
1233 | } catch (RecordMissingException $e) { |
1234 | return false; |
1235 | } |
1236 | } |
1237 | return false; |
1238 | } |
1239 | |
1240 | /** |
1241 | * Load parameters associated with a resumption token. |
1242 | * |
1243 | * @param string $token The resumption token to look up |
1244 | * |
1245 | * @return array Parameters associated with token |
1246 | */ |
1247 | protected function loadResumptionToken($token) |
1248 | { |
1249 | // Clean up expired records before doing our search: |
1250 | $this->resumptionService->removeExpired(); |
1251 | |
1252 | // Load the requested token if it still exists: |
1253 | if ($row = $this->resumptionService->findToken($token)) { |
1254 | parse_str($row->getResumptionParameters(), $params); |
1255 | return $params; |
1256 | } |
1257 | |
1258 | // If we got this far, the token is invalid or expired: |
1259 | return false; |
1260 | } |
1261 | |
1262 | /** |
1263 | * Normalize a date to a Unix timestamp. |
1264 | * |
1265 | * @param string $date Date (ISO-8601 or YYYY-MM-DD HH:MM:SS) |
1266 | * @param string $time Default time to use if $date has no time attached |
1267 | * |
1268 | * @return integer Unix timestamp (or false if $date invalid) |
1269 | */ |
1270 | protected function normalizeDate($date, $time = '00:00:00') |
1271 | { |
1272 | // Remove timezone markers -- we don't want PHP to outsmart us by adjusting |
1273 | // the time zone! |
1274 | if (strlen($date) == 10) { |
1275 | $date .= ' ' . $time; |
1276 | } else { |
1277 | $date = str_replace(['T', 'Z'], [' ', ''], $date); |
1278 | } |
1279 | |
1280 | // Translate to a timestamp: |
1281 | return strtotime($date); |
1282 | } |
1283 | |
1284 | /** |
1285 | * Prepend the OAI prefix to the provided ID number. |
1286 | * |
1287 | * @param string $id The ID to update. |
1288 | * |
1289 | * @return string The prefixed ID. |
1290 | */ |
1291 | protected function prefixID($id) |
1292 | { |
1293 | $prefix = empty($this->idNamespace) |
1294 | ? '' : 'oai:' . $this->idNamespace . ':'; |
1295 | return $prefix . $id; |
1296 | } |
1297 | |
1298 | /** |
1299 | * Generate a resumption token to continue the current operation. |
1300 | * |
1301 | * @param SimpleXMLElement $xml XML document to update with token. |
1302 | * @param array $params Current operational parameters. |
1303 | * @param int $currentCursor Current cursor position in search |
1304 | * results. |
1305 | * @param int $listSize Total size of search results. |
1306 | * @param string $cursorMark cursorMark for the position in the full |
1307 | * results list. |
1308 | * |
1309 | * @return void |
1310 | */ |
1311 | protected function saveResumptionToken( |
1312 | $xml, |
1313 | $params, |
1314 | $currentCursor, |
1315 | $listSize, |
1316 | $cursorMark |
1317 | ) { |
1318 | // Save the old cursor position before overwriting it for storage in the |
1319 | // database! |
1320 | $oldCursor = $params['cursor']; |
1321 | $params['cursor'] = $currentCursor; |
1322 | $params['cursorMark'] = $cursorMark; |
1323 | |
1324 | // Save everything to the database: |
1325 | $expire = time() + 24 * 60 * 60; |
1326 | $token = $this->resumptionService->createAndPersistToken($params, $expire)->getId(); |
1327 | |
1328 | // Add details to the xml: |
1329 | $token = $xml->addChild('resumptionToken', $token); |
1330 | $token->addAttribute('cursor', $oldCursor); |
1331 | $token->addAttribute('expirationDate', date($this->iso8601, $expire)); |
1332 | $token->addAttribute('completeListSize', $listSize); |
1333 | } |
1334 | |
1335 | /** |
1336 | * Display an error response. |
1337 | * |
1338 | * @param string $code The error code to display |
1339 | * @param string $message The error string to display |
1340 | * |
1341 | * @return string |
1342 | */ |
1343 | protected function showError($code, $message) |
1344 | { |
1345 | // Certain errors should not echo parameters: |
1346 | $echoParams = !($code == 'badVerb' || $code == 'badArgument'); |
1347 | $response = $this->createResponse($echoParams); |
1348 | |
1349 | $xml = $response->addChild('error', htmlspecialchars($message)); |
1350 | if (!empty($code)) { |
1351 | $xml['code'] = $code; |
1352 | } |
1353 | |
1354 | return $response->asXML(); |
1355 | } |
1356 | |
1357 | /** |
1358 | * Create an OAI-PMH response (shared support method used by various |
1359 | * response-specific methods). |
1360 | * |
1361 | * @param bool $echoParams Include params in <request> tag? |
1362 | * |
1363 | * @return SimpleXMLElement |
1364 | */ |
1365 | protected function createResponse($echoParams = true) |
1366 | { |
1367 | // Set up standard response wrapper: |
1368 | $xml = simplexml_load_string( |
1369 | '<OAI-PMH xmlns="http://www.openarchives.org/OAI/2.0/" />' |
1370 | ); |
1371 | $xml->addAttribute( |
1372 | 'xsi:schemaLocation', |
1373 | 'http://www.openarchives.org/OAI/2.0/ ' |
1374 | . 'http://www.openarchives.org/OAI/2.0/OAI-PMH.xsd', |
1375 | 'http://www.w3.org/2001/XMLSchema-instance' |
1376 | ); |
1377 | $xml->responseDate = $this->getUTCDateTime('now'); |
1378 | $xml->request = $this->baseURL; |
1379 | if ($echoParams) { |
1380 | foreach ($this->params as $key => $value) { |
1381 | $xml->request[$key] = $value; |
1382 | } |
1383 | } |
1384 | |
1385 | return $xml; |
1386 | } |
1387 | |
1388 | /** |
1389 | * Strip the OAI prefix from the provided ID number. |
1390 | * |
1391 | * @param string $id The ID to strip. |
1392 | * |
1393 | * @return string The stripped ID (false if prefix invalid). |
1394 | */ |
1395 | protected function stripID($id) |
1396 | { |
1397 | // No prefix? No stripping! |
1398 | if (empty($this->idNamespace)) { |
1399 | return $id; |
1400 | } |
1401 | |
1402 | // Prefix? Strip it off and return the stripped version if valid: |
1403 | $prefix = 'oai:' . $this->idNamespace . ':'; |
1404 | if (str_starts_with($id, $prefix)) { |
1405 | return substr($id, strlen($prefix)); |
1406 | } |
1407 | |
1408 | // Invalid prefix -- unrecognized ID: |
1409 | return false; |
1410 | } |
1411 | |
1412 | /** |
1413 | * Die with an unexpected error code (when something outside the scope of |
1414 | * OAI-PMH fails). |
1415 | * |
1416 | * @param string $msg Error message |
1417 | * |
1418 | * @throws \Exception |
1419 | * @return void |
1420 | */ |
1421 | protected function unexpectedError($msg) |
1422 | { |
1423 | throw new \Exception("Unexpected fatal error -- {$msg}."); |
1424 | } |
1425 | } |