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