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 /** 108 * @param array $params 109 * @return string 110 */ 111 private function makeCurlRequest( array $params ): string { 112 $ch = curl_init( $this->endpoint ); 113 if ( !$ch ) { 114 throw new \RuntimeException( "Failed to open curl handle to $this->endpoint" ); 115 } 116 $reset = new ScopedCallback( 'curl_close', [ $ch ] ); 117 118 $params['format'] = 'json'; 119 if ( !isset( $params['formatversion'] ) ) { 120 $params['formatversion'] = '2'; 121 } 122 123 $opts = [ 124 CURLOPT_POST => true, 125 CURLOPT_POSTFIELDS => $params, 126 ] + $this->curlopt; 127 if ( !curl_setopt_array( $ch, $opts ) ) { 128 throw new \RuntimeException( "Error setting curl options: " . curl_error( $ch ) ); 129 } 130 131 $res = curl_exec( $ch ); 132 133 if ( curl_errno( $ch ) !== 0 ) { 134 throw new \RuntimeException( "HTTP request failed: " . curl_error( $ch ) ); 135 } 136 137 $code = curl_getinfo( $ch, CURLINFO_RESPONSE_CODE ); 138 if ( $code !== 200 ) { 139 throw new \RuntimeException( "HTTP request failed: HTTP code $code" ); 140 } 141 142 ScopedCallback::consume( $reset ); 143 144 if ( !$res ) { 145 throw new \RuntimeException( "HTTP request failed: Empty response" ); 146 } 147 148 return $res; 149 } 150 151 /** 152 * @param array $parsoidSettings 153 * @return ApiHelper 154 */ 155 public static function fromSettings( array $parsoidSettings ): ApiHelper { 156 return new ApiHelper( [ 157 "apiEndpoint" => $parsoidSettings['debugApi'], 158 ] ); 159 } 160 161} 162