Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.66% covered (success)
94.66%
124 / 131
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractWikiRequest
94.66% covered (success)
94.66%
124 / 131
66.67% covered (warning)
66.67%
2 / 3
25.10
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 generateSafeFragment
84.44% covered (warning)
84.44%
38 / 45
0.00% covered (danger)
0.00%
0 / 1
9.30
 fetchRenderedFragment
100.00% covered (success)
100.00%
84 / 84
100.00% covered (success)
100.00%
1 / 1
15
1<?php
2/**
3 * AbstractWiki Request Service
4 *
5 * @file
6 * @ingroup Extensions
7 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
8 * @license MIT
9 */
10
11namespace MediaWiki\Extension\WikiLambda\AbstractContent;
12
13use MediaWiki\Config\Config;
14use MediaWiki\Extension\WikiLambda\Cache\MemcachedWrapper;
15use MediaWiki\Extension\WikiLambda\HttpStatus;
16use MediaWiki\Extension\WikiLambda\ParserFunction\WikifunctionsPFragmentSanitiserTokenHandler;
17use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
18use MediaWiki\Extension\WikiLambda\WikifunctionCallException;
19use MediaWiki\Extension\WikiLambda\WikiLambdaServices;
20use MediaWiki\Extension\WikiLambda\ZObjectUtils;
21use MediaWiki\Http\HttpRequestFactory;
22use MediaWiki\Logger\LoggerFactory;
23use Psr\Log\LoggerInterface;
24
25class AbstractWikiRequest {
26
27    private MemcachedWrapper $objectCache;
28    private LoggerInterface $logger;
29
30    public function __construct(
31        private readonly Config $config,
32        private readonly HttpRequestFactory $httpRequestFactory
33    ) {
34        $this->objectCache = WikiLambdaServices::getMemcachedWrapper();
35        $this->logger = LoggerFactory::getInstance( 'WikiLambdaAbstract' );
36    }
37
38    /**
39     * Re-generates a safe Abstract Wikipedia fragment by requesting it remotely
40     * from Wikifunctions (repo), sanitising it, and storing it locally in the
41     * cache under both the fresh and the stale keys.
42     *
43     * Returns an associative array containing the cached value, either a
44     * successfully rendered and sanitised fragment, or a failed one.
45     *
46     * @param array $functionCall
47     * @param string $cacheKeyFresh
48     * @param string $cacheKeyStale
49     * @return array
50     */
51    public function generateSafeFragment(
52        array $functionCall,
53        string $cacheKeyFresh,
54        string $cacheKeyStale
55    ): array {
56        $valueToCache = [];
57        // Set initial TTL, for successful renders or 400/bad requests:
58        // * fresh value for at least 48 hours to ensure availability through timezones
59        // * stale value for a month
60        $staleValueTTL = $this->objectCache::TTL_MONTH;
61        $freshValueTTL = $this->objectCache::TTL_WEEK;
62
63        try {
64            // 1. Run fragment function call, should return a Z89/Html fragment object
65            $htmlFragment = $this->fetchRenderedFragment( $functionCall );
66
67            // 2. If successful, sanitize the Z89K1/Html fragment value
68            $sanitizedHtml = WikifunctionsPFragmentSanitiserTokenHandler::sanitiseHtmlFragment(
69                $this->logger,
70                $htmlFragment[ ZTypeRegistry::Z_HTML_FRAGMENT_VALUE ]
71            );
72
73            // 3. Cache sucessful sanitized fragment preview
74            $valueToCache[ 'success' ] = true;
75            $valueToCache[ 'value' ] = $sanitizedHtml;
76
77        } catch ( WikifunctionCallException $e ) {
78            // Cache the failed request
79            $valueToCache[ 'success' ] = false;
80            $valueToCache[ 'value' ] = $e->toArray();
81
82            // First, check if it's a user-triggered error. If so, debug-log with the extra data
83            // but don't make noise; use the default TTL for a request.
84            if ( $e->getHttpStatusCode() === HttpStatus::BAD_REQUEST ) {
85                $logContext = [];
86                if ( $e->hasZError() ) {
87                    $logContext[ 'zerror' ] = $e->getZError();
88                }
89                $this->logger->debug(
90                    __METHOD__ . ': AbstractWikiRequest::fetchRenderedFragment failed: {error}',
91                    [ 'error' => $e->getMessage() ] + $logContext
92                );
93            } else {
94                // For temporary server errors: reduce fresh TTL to a minute
95                $freshValueTTL = $this->objectCache::TTL_MINUTE;
96
97                // Possible error cases that are "our fault" and should be logged noisily:
98                // - HttpStatus::NOT_IMPLEMENTED (server not configured)
99                // - HttpStatus::INTERNAL_SERVER_ERROR
100                // Possible error cases that might be load issues, but log anyway for now:
101                // - HttpStatus::SERVICE_UNAVAILABLE
102                // - HttpStatus::FORBIDDEN
103                // - HttpStatus::TOO_MANY_REQUESTS (not currently emitted below)
104                if (
105                    $e->getHttpStatusCode() === HttpStatus::NOT_IMPLEMENTED ||
106                    $e->getHttpStatusCode() === HttpStatus::INTERNAL_SERVER_ERROR ||
107                    $e->getHttpStatusCode() === HttpStatus::SERVICE_UNAVAILABLE ||
108                    $e->getHttpStatusCode() === HttpStatus::FORBIDDEN ||
109                    $e->getHttpStatusCode() === HttpStatus::TOO_MANY_REQUESTS
110                ) {
111                    $this->logger->warning(
112                        __METHOD__ . ': AbstractWikiRequest::fetchRenderedFragment triggered a server issue: {error}',
113                        [
114                            'error' => $e->getMessage(),
115                            'exception' => $e
116                        ]
117                    );
118                } else {
119                    // Something's gone wrong as an unpected error state, log as an error:
120                    $this->logger->error(
121                        __METHOD__ . ': AbstractWikiRequest::fetchRenderedFragment has unhandled error: {error}',
122                        [
123                            'error' => $e->getMessage(),
124                            'exception' => $e
125                        ]
126                    );
127                }
128            }
129        }
130
131        // 4. Cache the response with both the fresh and the stale keys
132        $cachedValueStr = json_encode( $valueToCache );
133        $this->objectCache->set( $cacheKeyFresh, $cachedValueStr, $freshValueTTL );
134        $this->objectCache->set( $cacheKeyStale, $cachedValueStr, $staleValueTTL );
135
136        return $valueToCache;
137    }
138
139    /**
140     * Performs remote call to Wikifunctions' wikilambda_function_call Action API
141     *
142     * @param array $functionCall
143     * @return array HTML fragment (Z89) response object
144     * @throws WikifunctionCallException
145     */
146    public function fetchRenderedFragment( array $functionCall ): array {
147        // Base API URL from config
148        $targetUrl = $this->config->get( 'WikiLambdaClientTargetAPI' );
149        if ( !$targetUrl ) {
150            // Missing configuration, abstractwiki-not-implemented error
151            throw new WikifunctionCallException(
152                'apierror-abstractwiki_run_fragment-not-enabled',
153                HttpStatus::NOT_IMPLEMENTED
154            );
155        }
156        $apiUrl = $targetUrl . '/w/api.php';
157
158        // Stringify the function call
159        $functionCallEncoded = json_encode( $functionCall, JSON_THROW_ON_ERROR );
160
161        // Build POST params
162        $params = [
163            'format' => 'json',
164            'action' => 'wikilambda_function_call',
165            'wikilambda_function_call_zobject' => $functionCallEncoded,
166        ];
167
168        // Create and execute request
169        $request = $this->httpRequestFactory->create(
170            $apiUrl,
171            [
172                'method' => 'POST',
173                'postData' => $params
174            ],
175            __METHOD__
176        );
177
178        $status = $request->execute();
179        $httpStatusCode = $request->getStatus();
180
181        // HTTP 503
182        // Transport level error; Wikifunctions could not be reached.
183        if ( !$status->isOK() && $httpStatusCode === 0 ) {
184            throw new WikifunctionCallException(
185                'apierror-abstractwiki_run_fragment-service-unavailable',
186                HttpStatus::SERVICE_UNAVAILABLE
187            );
188        }
189
190        // HTTP 500
191        // Decode the response and check that content is a valid JSON object.
192        // If that's not the case, there's an unknown internal server error
193        $responseData = json_decode( $request->getContent(), true );
194        if ( !is_array( $responseData ) ) {
195            throw new WikifunctionCallException(
196                'apierror-abstractwiki_run_fragment-unknown-error',
197                HttpStatus::INTERNAL_SERVER_ERROR
198            );
199        }
200
201        // If there's an error key in the API response, it could be:
202        // * error.code !== 'wikilambda-zerror'
203        //   * 200/internal_api_error_* - some unknown error
204        //   * 429/apierror-wikilambda_function_call-concurrency-limit - too many requests
205        //   * 503/timeouterror-text - timeout
206        // * error.code === 'wikilambda-zerror'
207        //   * 503/Z529 - unable to connect to the orchestrator
208        //   * 403/Z559 - no permissions
209        //   * 400/Z501 - JSON styntax error
210        //   * 400/Z518 - ZObject type mismatch
211        if ( array_key_exists( 'error', $responseData ) ) {
212            $errorCode = $responseData[ 'error' ][ 'code' ];
213            // For 503 and 429, currently unavailable, try again later:
214            if (
215                $httpStatusCode === HttpStatus::SERVICE_UNAVAILABLE ||
216                $httpStatusCode === HttpStatus::TOO_MANY_REQUESTS
217            ) {
218                throw new WikifunctionCallException(
219                    'apierror-abstractwiki_run_fragment-service-unavailable',
220                    HttpStatus::SERVICE_UNAVAILABLE
221                );
222            }
223
224            // For 403, permissions error, return forbidden:
225            if ( $httpStatusCode === HttpStatus::FORBIDDEN ) {
226                throw new WikifunctionCallException(
227                    'apierror-abstractwiki_run_fragment-forbidden',
228                    HttpStatus::FORBIDDEN
229                );
230            }
231
232            // For 400, either wrong JSON or wrong ZObject type, return bad request:
233            if ( $httpStatusCode === HttpStatus::BAD_REQUEST ) {
234                throw new WikifunctionCallException(
235                    'apierror-abstractwiki_run_fragment-bad-fragment',
236                    HttpStatus::BAD_REQUEST
237                );
238            }
239
240            // Else, return unknown issue:
241            throw new WikifunctionCallException(
242                'apierror-abstractwiki_run_fragment-unknown-error',
243                HttpStatus::INTERNAL_SERVER_ERROR
244            );
245        }
246
247        // The response does not have an error, but also doesn't have the
248        // expected content, so throw an unknown error.
249        if ( !array_key_exists( 'wikilambda_function_call', $responseData ) ) {
250            throw new WikifunctionCallException(
251                'apierror-abstractwiki_run_fragment-unknown-error',
252                HttpStatus::INTERNAL_SERVER_ERROR
253            );
254        }
255
256        // Give phan some assistance on what we expect the response to look like
257        '@phan-var array{wikilambda_function_call?: array{data?: string}} $responseData';
258
259        $responseEnvelopeStr = $responseData[ 'wikilambda_function_call' ][ 'data' ] ?? '';
260        $responseEnvelope = json_decode( $responseEnvelopeStr );
261
262        // The response is not a valid JSON, which probably means
263        // that there is some unknown bug somewhere, return unknown:
264        if ( !is_object( $responseEnvelope ) ) {
265            throw new WikifunctionCallException(
266                'apierror-abstractwiki_run_fragment-unknown-error',
267                HttpStatus::INTERNAL_SERVER_ERROR
268            );
269        }
270
271        // Give phan some assistance on what we expect the envelope to look like
272        '@phan-var object{Z22K1:array<string,string|array>,Z22K2:array<string,string|array>} $responseEnvelope';
273
274        $htmlFragment = $responseEnvelope->{ ZTypeRegistry::Z_RESPONSEENVELOPE_VALUE };
275
276        // If the response value is Void, there is an error in the metadata.
277        // We can capture it and show some stuff.
278        if ( $htmlFragment === ZTypeRegistry::Z_VOID ) {
279            $metadata = $responseEnvelope->{ ZTypeRegistry::Z_RESPONSEENVELOPE_METADATA };
280            $zerror = ZObjectUtils::getErrorsFromMetadata( $metadata );
281
282            throw new WikifunctionCallException(
283                'apierror-abstractwiki_run_fragment-returned-zerror',
284                HttpStatus::BAD_REQUEST,
285                $zerror,
286                [ $zerror->{'Z5K1'} ?? 'No error code provided' ]
287            );
288        }
289
290        // We make sure that the result is a Z89/HTML fragment.
291        // If not the case, the fragment is probably wrong, so return 400:
292        if ( !is_object( $htmlFragment ) || !property_exists( $htmlFragment, ZTypeRegistry::Z_HTML_FRAGMENT_VALUE ) ) {
293            throw new WikifunctionCallException(
294                'apierror-abstractwiki_run_fragment-bad-response',
295                HttpStatus::BAD_REQUEST
296            );
297        }
298
299        return (array)$htmlFragment;
300    }
301}