Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 173 |
|
0.00% |
0 / 7 |
CRAP | |
0.00% |
0 / 1 |
SearchApiController | |
0.00% |
0 / 173 |
|
0.00% |
0 / 7 |
1482 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
30 | |||
getApiSpecFragment | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
2 | |||
onDispatch | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 | |||
recordAction | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
42 | |||
searchAction | |
0.00% |
0 / 62 |
|
0.00% |
0 / 1 |
240 | |||
getHierarchicalFacetData | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
30 | |||
getFieldList | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | |
3 | /** |
4 | * Search API Controller |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) The National Library of Finland 2015-2016. |
9 | * |
10 | * This program is free software; you can redistribute it and/or modify |
11 | * it under the terms of the GNU General Public License version 2, |
12 | * as published by the Free Software Foundation. |
13 | * |
14 | * This program is distributed in the hope that it will be useful, |
15 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
16 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
17 | * GNU General Public License for more details. |
18 | * |
19 | * You should have received a copy of the GNU General Public License |
20 | * along with this program; if not, write to the Free Software |
21 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
22 | * |
23 | * @category VuFind |
24 | * @package Controller |
25 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
26 | * @author Juha Luoma <juha.luoma@helsinki.fi> |
27 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
28 | * @link https://vufind.org/wiki/development:plugins:controllers Wiki |
29 | */ |
30 | |
31 | namespace VuFindApi\Controller; |
32 | |
33 | use Exception; |
34 | use Laminas\Http\Exception\InvalidArgumentException; |
35 | use Laminas\Mvc\Exception\DomainException; |
36 | use Laminas\ServiceManager\ServiceLocatorInterface; |
37 | use VuFindApi\Formatter\FacetFormatter; |
38 | use VuFindApi\Formatter\RecordFormatter; |
39 | |
40 | use function count; |
41 | use function is_array; |
42 | |
43 | /** |
44 | * Search API Controller |
45 | * |
46 | * Controls the Search API functionality |
47 | * |
48 | * @category VuFind |
49 | * @package Service |
50 | * @author Ere Maijala <ere.maijala@helsinki.fi> |
51 | * @author Juha Luoma <juha.luoma@helsinki.fi> |
52 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
53 | * @link https://vufind.org/wiki/development:plugins:controllers Wiki |
54 | */ |
55 | class SearchApiController extends \VuFind\Controller\AbstractSearch implements ApiInterface |
56 | { |
57 | use ApiTrait; |
58 | |
59 | /** |
60 | * Record formatter |
61 | * |
62 | * @var RecordFormatter |
63 | */ |
64 | protected $recordFormatter; |
65 | |
66 | /** |
67 | * Facet formatter |
68 | * |
69 | * @var FacetFormatter |
70 | */ |
71 | protected $facetFormatter; |
72 | |
73 | /** |
74 | * Default record fields to return if a request does not define the fields |
75 | * |
76 | * @var array |
77 | */ |
78 | protected $defaultRecordFields = []; |
79 | |
80 | /** |
81 | * Permission required for the record endpoint |
82 | * |
83 | * @var string |
84 | */ |
85 | protected $recordAccessPermission = 'access.api.Record'; |
86 | |
87 | /** |
88 | * Permission required for the search endpoint |
89 | * |
90 | * @var string |
91 | */ |
92 | protected $searchAccessPermission = 'access.api.Search'; |
93 | |
94 | /** |
95 | * Record route uri |
96 | * |
97 | * @var string |
98 | */ |
99 | protected $recordRoute = 'record'; |
100 | |
101 | /** |
102 | * Search route uri |
103 | * |
104 | * @var string |
105 | */ |
106 | protected $searchRoute = 'search'; |
107 | |
108 | /** |
109 | * Descriptive label for the index managed by this controller |
110 | * |
111 | * @var string |
112 | */ |
113 | protected $indexLabel = 'primary'; |
114 | |
115 | /** |
116 | * Prefix for use in model names used by API |
117 | * |
118 | * @var string |
119 | */ |
120 | protected $modelPrefix = ''; |
121 | |
122 | /** |
123 | * Max limit of search results in API response (default 100); |
124 | * |
125 | * @var int |
126 | */ |
127 | protected $maxLimit = 100; |
128 | |
129 | /** |
130 | * Constructor |
131 | * |
132 | * @param ServiceLocatorInterface $sm Service manager |
133 | * @param RecordFormatter $rf Record formatter |
134 | * @param FacetFormatter $ff Facet formatter |
135 | */ |
136 | public function __construct( |
137 | ServiceLocatorInterface $sm, |
138 | RecordFormatter $rf, |
139 | FacetFormatter $ff |
140 | ) { |
141 | parent::__construct($sm); |
142 | $this->recordFormatter = $rf; |
143 | $this->facetFormatter = $ff; |
144 | foreach ($rf->getRecordFields() as $fieldName => $fieldSpec) { |
145 | if (!empty($fieldSpec['vufind.default'])) { |
146 | $this->defaultRecordFields[] = $fieldName; |
147 | } |
148 | } |
149 | |
150 | // Load configurations from the search options class: |
151 | $settings = $sm->get(\VuFind\Search\Options\PluginManager::class) |
152 | ->get($this->searchClassId)->getAPISettings(); |
153 | |
154 | // Apply all supported configurations: |
155 | $configKeys = [ |
156 | 'recordAccessPermission', 'searchAccessPermission', 'maxLimit', |
157 | ]; |
158 | foreach ($configKeys as $key) { |
159 | if (isset($settings[$key])) { |
160 | $this->$key = $settings[$key]; |
161 | } |
162 | } |
163 | } |
164 | |
165 | /** |
166 | * Get API specification JSON fragment for services provided by the |
167 | * controller |
168 | * |
169 | * @return string |
170 | */ |
171 | public function getApiSpecFragment() |
172 | { |
173 | $config = $this->getConfig(); |
174 | $results = $this->getResultsManager()->get($this->searchClassId); |
175 | $options = $results->getOptions(); |
176 | $params = $results->getParams(); |
177 | |
178 | $viewParams = [ |
179 | 'config' => $config, |
180 | 'version' => \VuFind\Config\Version::getBuildVersion(), |
181 | 'searchTypes' => $options->getBasicHandlers(), |
182 | 'defaultSearchType' => $options->getDefaultHandler(), |
183 | 'recordFields' => $this->recordFormatter->getRecordFieldSpec(), |
184 | 'defaultFields' => $this->defaultRecordFields, |
185 | 'facetConfig' => $params->getFacetConfig(), |
186 | 'sortOptions' => $options->getSortOptions(), |
187 | 'defaultSort' => $options->getDefaultSortByHandler(), |
188 | 'recordRoute' => $this->recordRoute, |
189 | 'searchRoute' => $this->searchRoute, |
190 | 'searchIndex' => $this->searchClassId, |
191 | 'indexLabel' => $this->indexLabel, |
192 | 'modelPrefix' => $this->modelPrefix, |
193 | 'maxLimit' => $this->maxLimit, |
194 | ]; |
195 | $json = $this->getViewRenderer()->render( |
196 | 'searchapi/openapi', |
197 | $viewParams |
198 | ); |
199 | return $json; |
200 | } |
201 | |
202 | /** |
203 | * Execute the request |
204 | * |
205 | * @param \Laminas\Mvc\MvcEvent $e Event |
206 | * |
207 | * @return mixed |
208 | * @throws DomainException|InvalidArgumentException|Exception |
209 | */ |
210 | public function onDispatch(\Laminas\Mvc\MvcEvent $e) |
211 | { |
212 | // Add CORS headers and handle OPTIONS requests. This is a simplistic |
213 | // approach since we allow any origin. For more complete CORS handling |
214 | // a module like zfr-cors could be used. |
215 | $response = $this->getResponse(); |
216 | $headers = $response->getHeaders(); |
217 | $headers->addHeaderLine('Access-Control-Allow-Origin: *'); |
218 | $request = $this->getRequest(); |
219 | if ($request->getMethod() == 'OPTIONS') { |
220 | // Disable session writes |
221 | $this->disableSessionWrites(); |
222 | $headers->addHeaderLine( |
223 | 'Access-Control-Allow-Methods', |
224 | 'GET, POST, OPTIONS' |
225 | ); |
226 | $headers->addHeaderLine('Access-Control-Max-Age', '86400'); |
227 | |
228 | return $this->output(null, 204); |
229 | } |
230 | return parent::onDispatch($e); |
231 | } |
232 | |
233 | /** |
234 | * Record action |
235 | * |
236 | * @return \Laminas\Http\Response |
237 | */ |
238 | public function recordAction() |
239 | { |
240 | // Disable session writes |
241 | $this->disableSessionWrites(); |
242 | |
243 | $this->determineOutputMode(); |
244 | |
245 | if ($result = $this->isAccessDenied($this->recordAccessPermission)) { |
246 | return $result; |
247 | } |
248 | |
249 | $request = $this->getRequest()->getQuery()->toArray() |
250 | + $this->getRequest()->getPost()->toArray(); |
251 | |
252 | if (!isset($request['id'])) { |
253 | return $this->output([], self::STATUS_ERROR, 400, 'Missing id'); |
254 | } |
255 | |
256 | $loader = $this->getService(\VuFind\Record\Loader::class); |
257 | $results = []; |
258 | try { |
259 | if (is_array($request['id'])) { |
260 | $results = $loader->loadBatchForSource( |
261 | $request['id'], |
262 | $this->searchClassId |
263 | ); |
264 | } else { |
265 | $results[] = $loader->load($request['id'], $this->searchClassId); |
266 | } |
267 | } catch (Exception $e) { |
268 | return $this->output( |
269 | [], |
270 | self::STATUS_ERROR, |
271 | 400, |
272 | 'Error loading record' |
273 | ); |
274 | } |
275 | |
276 | $response = [ |
277 | 'resultCount' => count($results), |
278 | ]; |
279 | $requestedFields = $this->getFieldList($request); |
280 | if ($records = $this->recordFormatter->format($results, $requestedFields)) { |
281 | $response['records'] = $records; |
282 | } |
283 | |
284 | return $this->output($response, self::STATUS_OK); |
285 | } |
286 | |
287 | /** |
288 | * Search action |
289 | * |
290 | * @return \Laminas\Http\Response |
291 | */ |
292 | public function searchAction() |
293 | { |
294 | // Disable session writes |
295 | $this->disableSessionWrites(); |
296 | |
297 | $this->determineOutputMode(); |
298 | |
299 | if ($result = $this->isAccessDenied($this->searchAccessPermission)) { |
300 | return $result; |
301 | } |
302 | |
303 | // Send both GET and POST variables to search class: |
304 | $request = $this->getRequest()->getQuery()->toArray() |
305 | + $this->getRequest()->getPost()->toArray(); |
306 | |
307 | if ( |
308 | isset($request['limit']) |
309 | && (!ctype_digit($request['limit']) |
310 | || $request['limit'] < 0 || $request['limit'] > $this->maxLimit) |
311 | ) { |
312 | return $this->output([], self::STATUS_ERROR, 400, 'Invalid limit'); |
313 | } |
314 | |
315 | // Sort by relevance by default |
316 | if (!isset($request['sort'])) { |
317 | $request['sort'] = 'relevance'; |
318 | } |
319 | |
320 | $requestedFields = $this->getFieldList($request); |
321 | |
322 | $facetConfig = $this->getConfig('facets'); |
323 | $hierarchicalFacets = isset($facetConfig->SpecialFacets->hierarchical) |
324 | ? $facetConfig->SpecialFacets->hierarchical->toArray() |
325 | : []; |
326 | |
327 | $runner = $this->getService(\VuFind\Search\SearchRunner::class); |
328 | try { |
329 | $results = $runner->run( |
330 | $request, |
331 | $this->searchClassId, |
332 | function ( |
333 | $runner, |
334 | $params, |
335 | $searchId |
336 | ) use ( |
337 | $hierarchicalFacets, |
338 | $request, |
339 | $requestedFields |
340 | ) { |
341 | foreach ($request['facet'] ?? [] as $facet) { |
342 | if (!isset($hierarchicalFacets[$facet])) { |
343 | $params->addFacet($facet); |
344 | } |
345 | } |
346 | if ($requestedFields) { |
347 | $limit = $request['limit'] ?? 20; |
348 | $params->setLimit($limit); |
349 | } else { |
350 | $params->setLimit(0); |
351 | } |
352 | } |
353 | ); |
354 | } catch (Exception $e) { |
355 | return $this->output([], self::STATUS_ERROR, 400, $e->getMessage()); |
356 | } |
357 | |
358 | // If we received an EmptySet back, that indicates that the real search |
359 | // failed due to some kind of syntax error, and we should display a |
360 | // warning to the user; otherwise, we should proceed with normal post-search |
361 | // processing. |
362 | if ($results instanceof \VuFind\Search\EmptySet\Results) { |
363 | return $this->output([], self::STATUS_ERROR, 400, 'Invalid search'); |
364 | } |
365 | |
366 | $response = ['resultCount' => $results->getResultTotal()]; |
367 | |
368 | $records = $this->recordFormatter->format( |
369 | $results->getResults(), |
370 | $requestedFields |
371 | ); |
372 | if ($records) { |
373 | $response['records'] = $records; |
374 | } |
375 | |
376 | $requestedFacets = $request['facet'] ?? []; |
377 | $hierarchicalFacetData = $this->getHierarchicalFacetData( |
378 | array_intersect($requestedFacets, $hierarchicalFacets) |
379 | ); |
380 | $facets = $this->facetFormatter->format( |
381 | $request, |
382 | $results, |
383 | $hierarchicalFacetData |
384 | ); |
385 | if ($facets) { |
386 | $response['facets'] = $facets; |
387 | } |
388 | |
389 | return $this->output($response, self::STATUS_OK); |
390 | } |
391 | |
392 | /** |
393 | * Get hierarchical facet data for the given facet fields |
394 | * |
395 | * @param array $facets Facet fields |
396 | * |
397 | * @return array |
398 | */ |
399 | protected function getHierarchicalFacetData($facets) |
400 | { |
401 | if (!$facets) { |
402 | return []; |
403 | } |
404 | $results = $this->getResultsManager()->get('Solr'); |
405 | $params = $results->getParams(); |
406 | foreach ($facets as $facet) { |
407 | $params->addFacet($facet, null, false); |
408 | } |
409 | $params->initFromRequest($this->getRequest()->getQuery()); |
410 | |
411 | $facetResults = $results->getFullFieldFacets($facets, false, -1, 'count'); |
412 | |
413 | $facetHelper = $this->getService(\VuFind\Search\Solr\HierarchicalFacetHelper::class); |
414 | |
415 | $facetList = []; |
416 | foreach ($facets as $facet) { |
417 | if (empty($facetResults[$facet]['data']['list'])) { |
418 | $facetList[$facet] = []; |
419 | continue; |
420 | } |
421 | $facetList[$facet] = $facetHelper->buildFacetArray( |
422 | $facet, |
423 | $facetResults[$facet]['data']['list'], |
424 | $results->getUrlQuery(), |
425 | false |
426 | ); |
427 | $facetList[$facet] = $facetHelper->filterFacets($facet, $facetList[$facet], $results->getOptions()); |
428 | } |
429 | |
430 | return $facetList; |
431 | } |
432 | |
433 | /** |
434 | * Get field list based on the request |
435 | * |
436 | * @param array $request Request params |
437 | * |
438 | * @return array |
439 | */ |
440 | protected function getFieldList($request) |
441 | { |
442 | $fieldList = []; |
443 | if (isset($request['field'])) { |
444 | if (!empty($request['field']) && is_array($request['field'])) { |
445 | $fieldList = $request['field']; |
446 | } |
447 | } else { |
448 | $fieldList = $this->defaultRecordFields; |
449 | } |
450 | return $fieldList; |
451 | } |
452 | } |