Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiHelper
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 3
272
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
6
 makeRequest
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
72
 makeCurlRequest
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3declare( strict_types = 1 );
4
5namespace Wikimedia\Parsoid\Config\Api;
6
7use Wikimedia\ScopedCallback;
8
9class ApiHelper {
10
11    /** @var string */
12    private $endpoint;
13
14    /** @var array */
15    private $curlopt;
16
17    /** @var string */
18    private $cacheDir;
19
20    /** @var bool|string */
21    private $writeToCache;
22
23    /**
24     * @param array $opts
25     *  - apiEndpoint: (string) URL for api.php. Required.
26     *  - apiTimeout: (int) Timeout, in sections. Default 60.
27     *  - userAgent: (string) User agent prefix.
28     *  - cacheDir: (string) If present, looks aside to the specified directory
29     *    for a cached response before making a network request.
30     *  - writeToCache: (bool|string) If present and truthy, writes successful
31     *    network requests to `cacheDir` so they can be reused.  If set to
32     *    the string 'pretty', prettifies the JSON returned before writing it.
33     */
34    public function __construct( array $opts ) {
35        if ( !isset( $opts['apiEndpoint'] ) ) {
36            throw new \InvalidArgumentException( '$opts[\'apiEndpoint\'] must be set' );
37        }
38        $this->endpoint = $opts['apiEndpoint'];
39
40        $this->cacheDir = $opts['cacheDir'] ?? null;
41        $this->writeToCache = $opts['writeToCache'] ?? false;
42
43        $this->curlopt = [
44            CURLOPT_USERAGENT => trim( ( $opts['userAgent'] ?? '' ) . ' ApiEnv/1.0 Parsoid-PHP/0.1' ),
45            CURLOPT_CONNECTTIMEOUT => $opts['apiTimeout'] ?? 60,
46            CURLOPT_TIMEOUT => $opts['apiTimeout'] ?? 60,
47            CURLOPT_FOLLOWLOCATION => false,
48            CURLOPT_ENCODING => '', // Enable compression
49            CURLOPT_SAFE_UPLOAD => true,
50            CURLOPT_RETURNTRANSFER => true,
51        ];
52    }
53
54    /**
55     * Make an API request
56     * @param array $params API parameters
57     * @return array API response data
58     */
59    public function makeRequest( array $params ): array {
60        $filename = null;
61        $params += [ 'formatversion' => 2 ];
62        if ( $this->cacheDir !== null ) {
63            # sort the parameters for a repeatable filename
64            ksort( $params );
65            $query = $this->endpoint . "?" . http_build_query( $params );
66            $queryHash = hash( 'sha256', $query );
67            $filename = $this->cacheDir . DIRECTORY_SEPARATOR .
68                parse_url( $query, PHP_URL_HOST ) . '-' .
69                substr( $queryHash, 0, 8 );
70            if ( file_exists( $filename ) ) {
71                $res = file_get_contents( $filename );
72                $filename = null; // We don't need to write this back
73            } else {
74                $res = $this->makeCurlRequest( $params );
75            }
76        } else {
77            $res = $this->makeCurlRequest( $params );
78        }
79
80        $data = json_decode( $res, true );
81        if ( !is_array( $data ) ) {
82            throw new \RuntimeException( "HTTP request failed: Response was not a JSON array" );
83        }
84
85        if ( isset( $data['error'] ) ) {
86            $e = $data['error'];
87            throw new \RuntimeException( "MediaWiki API error: [{$e['code']}{$e['info']}" );
88        }
89
90        if ( $filename && $this->writeToCache ) {
91            if ( $this->writeToCache === 'pretty' ) {
92                /* Prettify the results */
93                $dataPretty = [
94                    '__endpoint__' => $this->endpoint,
95                    '__params__' => $params,
96                ] + $data;
97                $res = json_encode(
98                    $dataPretty, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
99                );
100            }
101            file_put_contents( $filename, $res );
102        }
103
104        return $data;
105    }
106
107    private function makeCurlRequest( array $params ): string {
108        $ch = curl_init( $this->endpoint );
109        if ( !$ch ) {
110            throw new \RuntimeException( "Failed to open curl handle to $this->endpoint" );
111        }
112        $reset = new ScopedCallback( 'curl_close', [ $ch ] );
113
114        $params['format'] = 'json';
115        $params['formatversion'] ??= '2';
116
117        $opts = [
118            CURLOPT_POST => true,
119            CURLOPT_POSTFIELDS => $params,
120        ] + $this->curlopt;
121        if ( !curl_setopt_array( $ch, $opts ) ) {
122            throw new \RuntimeException( "Error setting curl options: " . curl_error( $ch ) );
123        }
124
125        $res = curl_exec( $ch );
126
127        if ( curl_errno( $ch ) !== 0 ) {
128            throw new \RuntimeException( "HTTP request failed: " . curl_error( $ch ) );
129        }
130
131        $code = curl_getinfo( $ch, CURLINFO_RESPONSE_CODE );
132        if ( $code !== 200 ) {
133            throw new \RuntimeException( "HTTP request failed: HTTP code $code" );
134        }
135
136        ScopedCallback::consume( $reset );
137
138        if ( !$res ) {
139            throw new \RuntimeException( "HTTP request failed: Empty response" );
140        }
141
142        return $res;
143    }
144}