Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
2.60% covered (danger)
2.60%
2 / 77
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractAPI
2.60% covered (danger)
2.60%
2 / 77
0.00% covered (danger)
0.00%
0 / 6
469.26
0.00% covered (danger)
0.00%
0 / 1
 preRequest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 debugRequest
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 failureCodeIsAllowed
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 shouldRetryAfterUnexpectedStatusCode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeRequest
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
182
 setConfig
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2
3/**
4 * Abstract Driver for API-based ILS drivers
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2018.
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  ILS_Drivers
25 * @author   Chris Hallberg <challber@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
28 */
29
30namespace VuFind\ILS\Driver;
31
32use Laminas\Http\Response;
33use Laminas\Log\LoggerAwareInterface;
34use VuFind\Exception\BadConfig;
35use VuFind\Exception\ILS as ILSException;
36use VuFindHttp\HttpServiceAwareInterface;
37
38use function in_array;
39use function is_string;
40
41/**
42 * Abstract Driver for API-based ILS drivers
43 *
44 * @category VuFind
45 * @package  ILS_Drivers
46 * @author   Chris Hallberg <challber@villanova.edu>
47 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
48 * @link     https://vufind.org/wiki/development:plugins:ils_drivers Wiki
49 */
50abstract class AbstractAPI extends AbstractBase implements
51    HttpServiceAwareInterface,
52    LoggerAwareInterface
53{
54    use \VuFind\Log\LoggerAwareTrait {
55        logError as error;
56    }
57    use \VuFindHttp\HttpServiceAwareTrait;
58
59    /**
60     * Allow default corrections to all requests
61     *
62     * @param \Laminas\Http\Headers $headers the request headers
63     * @param array                 $params  the parameters object
64     *
65     * @return array
66     */
67    protected function preRequest(\Laminas\Http\Headers $headers, $params)
68    {
69        return [$headers, $params];
70    }
71
72    /**
73     * Function that obscures and logs debug data
74     *
75     * @param string                $method      Request method
76     * (GET/POST/PUT/DELETE/etc.)
77     * @param string                $path        Request URL
78     * @param array                 $params      Request parameters
79     * @param \Laminas\Http\Headers $req_headers Headers object
80     *
81     * @return void
82     */
83    protected function debugRequest($method, $path, $params, $req_headers)
84    {
85        $logParams = [];
86        $logHeaders = [];
87        if ($method == 'GET') {
88            $logParams = $params;
89            $logHeaders = $req_headers->toArray();
90        }
91        $this->debug(
92            $method . ' request.' .
93            ' URL: ' . $path . '.' .
94            ' Params: ' . $this->varDump($logParams) . '.' .
95            ' Headers: ' . $this->varDump($logHeaders)
96        );
97    }
98
99    /**
100     * Does $code match the setting for allowed failure codes?
101     *
102     * @param int               $code                Code to check.
103     * @param true|int[]|string $allowedFailureCodes HTTP failure codes that should
104     * NOT cause an ILSException to be thrown. May be an array of integers, a regular
105     * expression, or boolean true to allow all codes.
106     *
107     * @return bool
108     */
109    protected function failureCodeIsAllowed(int $code, $allowedFailureCodes): bool
110    {
111        if ($allowedFailureCodes === true) {    // "allow everything" case
112            return true;
113        }
114        return is_string($allowedFailureCodes)
115            ? preg_match($allowedFailureCodes, (string)$code)
116            : in_array($code, (array)$allowedFailureCodes);
117    }
118
119    /**
120     * Support method for makeRequest to process an unexpected status code. Can return true to trigger
121     * a retry of the API call or false to throw an exception.
122     *
123     * @param Response $response      HTTP response
124     * @param int      $attemptNumber Counter to keep track of attempts (starts at 1 for the first attempt)
125     *
126     * @return bool
127     *
128     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
129     */
130    protected function shouldRetryAfterUnexpectedStatusCode(Response $response, int $attemptNumber): bool
131    {
132        // No retries by default.
133        return false;
134    }
135
136    /**
137     * Make requests
138     *
139     * @param string            $method              GET/POST/PUT/DELETE/etc
140     * @param string            $path                API path (with a leading /)
141     * @param string|array      $params              Query parameters
142     * @param array             $headers             Additional headers
143     * @param true|int[]|string $allowedFailureCodes HTTP failure codes that should
144     * NOT cause an ILSException to be thrown. May be an array of integers, a regular
145     * expression, or boolean true to allow all codes.
146     * @param string|array      $debugParams         Value to use in place of $params
147     * in debug messages (useful for concealing sensitive data, etc.)
148     * @param int               $attemptNumber       Counter to keep track of attempts
149     * (starts at 1 for the first attempt)
150     *
151     * @return \Laminas\Http\Response
152     * @throws ILSException
153     */
154    public function makeRequest(
155        $method = 'GET',
156        $path = '/',
157        $params = [],
158        $headers = [],
159        $allowedFailureCodes = [],
160        $debugParams = null,
161        $attemptNumber = 1
162    ) {
163        $client = $this->httpService->createClient(
164            $this->config['API']['base_url'] . $path,
165            $method,
166            120
167        );
168
169        // Add default headers and parameters
170        $req_headers = $client->getRequest()->getHeaders();
171        $req_headers->addHeaders($headers);
172        [$req_headers, $params] = $this->preRequest($req_headers, $params);
173
174        if ($this->logger) {
175            $this->debugRequest($method, $path, $debugParams ?? $params, $req_headers);
176        }
177
178        // Add params
179        if ($method == 'GET') {
180            $client->setParameterGet($params);
181        } else {
182            if (is_string($params)) {
183                $client->getRequest()->setContent($params);
184            } else {
185                $client->setParameterPost($params);
186            }
187        }
188        try {
189            $response = $client->send();
190        } catch (\Exception $e) {
191            $this->logError('Unexpected ' . $e::class . ': ' . (string)$e);
192            throw new ILSException('Error during send operation.');
193        }
194        $code = $response->getStatusCode();
195        if (
196            !$response->isSuccess()
197            && !$this->failureCodeIsAllowed($code, $allowedFailureCodes)
198        ) {
199            $this->logError(
200                "Unexpected error response (attempt #$attemptNumber"
201                . "); code: {$response->getStatusCode()}, body: {$response->getBody()}"
202            );
203            if ($this->shouldRetryAfterUnexpectedStatusCode($response, $attemptNumber)) {
204                return $this->makeRequest(
205                    $method,
206                    $path,
207                    $params,
208                    $headers,
209                    $allowedFailureCodes,
210                    $debugParams,
211                    $attemptNumber + 1
212                );
213            } else {
214                throw new ILSException('Unexpected error code.');
215            }
216        }
217        if ($jsonLog = ($this->config['API']['json_log_file'] ?? false)) {
218            if (APPLICATION_ENV !== 'development') {
219                $this->logError(
220                    'SECURITY: json_log_file enabled outside of development mode; disabling feature.'
221                );
222            } else {
223                $body = $response->getBody();
224                $jsonBody = @json_decode($body);
225                $json = file_exists($jsonLog)
226                    ? json_decode(file_get_contents($jsonLog)) : [];
227                $json[] = [
228                    'expectedMethod' => $method,
229                    'expectedPath' => $path,
230                    'expectedParams' => $params,
231                    'body' => $jsonBody ? $jsonBody : $body,
232                    'bodyType' => $jsonBody ? 'json' : 'string',
233                    'status' => $code,
234                ];
235                file_put_contents($jsonLog, json_encode($json));
236            }
237        }
238        return $response;
239    }
240
241    /**
242     * Set the configuration for the driver.
243     *
244     * @param array $config Configuration array (usually loaded from a VuFind .ini
245     * file whose name corresponds with the driver class name).
246     *
247     * @throws BadConfig if base url excluded
248     * @return void
249     */
250    public function setConfig($config)
251    {
252        parent::setConfig($config);
253        // Base URL required for API drivers
254        if (!isset($config['API']['base_url'])) {
255            throw new BadConfig('API Driver configured without base url.');
256        }
257    }
258}