Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 174
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchApiController
0.00% covered (danger)
0.00%
0 / 174
0.00% covered (danger)
0.00%
0 / 7
1482
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 getApiSpecFragment
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
2
 onDispatch
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 recordAction
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
42
 searchAction
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
240
 getHierarchicalFacetData
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
 getFieldList
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
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
31namespace VuFindApi\Controller;
32
33use Exception;
34use Laminas\Http\Exception\InvalidArgumentException;
35use Laminas\Mvc\Exception\DomainException;
36use Laminas\ServiceManager\ServiceLocatorInterface;
37use VuFindApi\Formatter\FacetFormatter;
38use VuFindApi\Formatter\RecordFormatter;
39
40use function count;
41use 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 */
55class 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->serviceLocator->get(\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->serviceLocator->get(\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->serviceLocator
414            ->get(\VuFind\Search\Solr\HierarchicalFacetHelper::class);
415
416        $facetList = [];
417        foreach ($facets as $facet) {
418            if (empty($facetResults[$facet]['data']['list'])) {
419                $facetList[$facet] = [];
420                continue;
421            }
422            $facetList[$facet] = $facetHelper->buildFacetArray(
423                $facet,
424                $facetResults[$facet]['data']['list'],
425                $results->getUrlQuery(),
426                false
427            );
428            $facetList[$facet] = $facetHelper->filterFacets($facet, $facetList[$facet], $results->getOptions());
429        }
430
431        return $facetList;
432    }
433
434    /**
435     * Get field list based on the request
436     *
437     * @param array $request Request params
438     *
439     * @return array
440     */
441    protected function getFieldList($request)
442    {
443        $fieldList = [];
444        if (isset($request['field'])) {
445            if (!empty($request['field']) && is_array($request['field'])) {
446                $fieldList = $request['field'];
447            }
448        } else {
449            $fieldList = $this->defaultRecordFields;
450        }
451        return $fieldList;
452    }
453}