Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 137
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
TranslationWebService
0.00% covered (danger)
0.00%
0 / 137
0.00% covered (danger)
0.00%
0 / 13
812
0.00% covered (danger)
0.00%
0 / 1
 factory
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
20
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQueries
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getResultData
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getType
n/a
0 / 0
n/a
0 / 0
0
 mapCode
n/a
0 / 0
n/a
0 / 0
0
 doPairs
n/a
0 / 0
n/a
0 / 0
0
 getQuery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0
 parseResponse
n/a
0 / 0
n/a
0 / 0
0
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 isSupportedLanguagePair
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getSupportedLanguagePairs
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 wrapUntranslatable
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 unwrapUntranslatable
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 checkTranslationServiceFailure
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 reportTranslationServiceFailure
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\WebService;
5
6use Exception;
7use MediaWiki\Logger\LoggerFactory;
8use MediaWiki\MediaWikiServices;
9use ObjectCache;
10use Psr\Log\LoggerAwareInterface;
11use Psr\Log\LoggerInterface;
12
13/**
14 * Multipurpose class:
15 *  - 1) Interface for web services.
16 *  - 2) Source text picking logic.
17 *  - 3) Factory class.
18 *  - 4) Service failure tracking and suspending.
19 * @author Niklas Laxström
20 * @license GPL-2.0-or-later
21 * @since 2013-01-01
22 * @defgroup TranslationWebService Translation Web Services
23 */
24abstract class TranslationWebService implements LoggerAwareInterface {
25    /* Public api */
26
27    /**
28     * Get a webservice handler.
29     * @see $wgTranslateTranslationServices
30     */
31    public static function factory( string $serviceName, array $config ): ?TranslationWebService {
32        $handlers = [
33            'microsoft' => [
34                'class' => MicrosoftWebService::class,
35                'deps' => [ 'HttpRequestFactory' ]
36            ],
37            'apertium' => [
38                'class' => ApertiumWebService::class,
39                'deps' => [ 'HttpRequestFactory' ]
40            ],
41            'yandex' => [
42                'class' => YandexWebService::class,
43                'deps' => [ 'HttpRequestFactory' ]
44            ],
45            'google' => [
46                'class' => GoogleTranslateWebService::class,
47                'deps' => [ 'HttpRequestFactory' ]
48            ],
49            'remote-ttmserver' => [
50                'class' => RemoteTTMServerWebService::class
51            ],
52            'cxserver' => [
53                'class' => ApertiumCxserverWebService::class,
54                'deps' => [ 'HttpRequestFactory' ]
55            ],
56            'restbase' => [
57                'class' => RESTBaseWebService::class,
58                'deps' => [ 'HttpRequestFactory' ]
59            ],
60            'caighdean' => [
61                'class' => CaighdeanWebService::class
62            ],
63            'mint' => [
64                'class' => MintCxserverWebService::class,
65                'deps' => [ 'HttpRequestFactory' ]
66            ]
67        ];
68
69        if ( !isset( $config['timeout'] ) ) {
70            $config['timeout'] = 3;
71        }
72
73        $serviceDetails = $handlers[$config['type']] ?? null;
74        if ( $serviceDetails ) {
75            $objectFactory = MediaWikiServices::getInstance()->getObjectFactory();
76            $spec = [
77                'class' => $serviceDetails['class'],
78                'args' => [ $serviceName, $config ],
79                'services' => $serviceDetails['deps'] ?? [],
80            ];
81
82            // @phan-suppress-next-line PhanTypeInvalidCallableArraySize due to annotations on createObject?
83            $serviceObject = $objectFactory->createObject( $spec );
84            if ( $serviceObject instanceof LoggerAwareInterface ) {
85                $serviceObject->setLogger( LoggerFactory::getInstance( 'translationservices' ) );
86            }
87
88            return $serviceObject;
89        }
90
91        return null;
92    }
93
94    /**
95     * Gets the name of this service, for example to display it for the user.
96     * @since 2014.02
97     */
98    public function getName(): string {
99        return $this->service;
100    }
101
102    /**
103     * Get queries for this service. Queries from multiple services can be
104     * collected and run asynchronously with QueryAggregator.
105     * @return TranslationQuery[]
106     * @since 2015.12
107     * @throws TranslationWebServiceConfigurationException
108     */
109    public function getQueries( string $text, string $sourceLanguage, string $targetLanguage ): array {
110        $from = $this->mapCode( $sourceLanguage );
111        $to = $this->mapCode( $targetLanguage );
112
113        try {
114            return [ $this->getQuery( $text, $from, $to ) ];
115        } catch ( TranslationWebServiceException $e ) {
116            $this->reportTranslationServiceFailure( $e->getMessage() );
117            return [];
118        } catch ( TranslationWebServiceInvalidInputException $e ) {
119            // Not much we can do about this, just ignore.
120            return [];
121        }
122    }
123
124    /**
125     * Get the web service specific response returned by QueryAggregator.
126     * @return mixed|null Returns null on error.
127     * @since 2015.12
128     */
129    public function getResultData( TranslationQueryResponse $response ) {
130        if ( $response->getStatusCode() !== 200 ) {
131            $this->reportTranslationServiceFailure(
132                'STATUS: ' . $response->getStatusMessage() . "\n" .
133                'BODY: ' . $response->getBody()
134            );
135            return null;
136        }
137
138        try {
139            return $this->parseResponse( $response );
140        } catch ( TranslationWebServiceException $e ) {
141            $this->reportTranslationServiceFailure( $e->getMessage() );
142            return null;
143        } catch ( TranslationWebServiceInvalidInputException $e ) {
144            // Not much we can do about this, just ignore.
145            return null;
146        }
147    }
148
149    /**
150     * Returns the type of this web service.
151     * @see \MediaWiki\Extension\Translate\TranslatorInterface\Aid\TranslationAid::getTypes
152     */
153    abstract public function getType(): string;
154
155    /* Service api */
156
157    /**
158     * Map a MediaWiki (almost standard) language code to the code used by the
159     * translation service.
160     */
161    abstract protected function mapCode( string $code ): string;
162
163    /**
164     * Get the list of supported language pairs for the web service. The codes
165     * should be the ones used by the service. Caching is handled by the public
166     * getSupportedLanguagePairs.
167     * @return array $list[source language][target language] = true
168     * @throws TranslationWebServiceException
169     * @throws TranslationWebServiceConfigurationException
170     */
171    abstract protected function doPairs(): array;
172
173    /**
174     * Get the query. See getQueries for the public method.
175     * @param string $text Text to translate.
176     * @param string $sourceLanguage Language code of the text, as used by the service.
177     * @param string $targetLanguage Language code of the translation, as used by the service.
178     * @since 2015.02
179     * @throws TranslationWebServiceException
180     * @throws TranslationWebServiceConfigurationException
181     * @throws TranslationWebServiceInvalidInputException
182     */
183    abstract protected function getQuery(
184        string $text, string $sourceLanguage, string $targetLanguage
185    ): TranslationQuery;
186
187    /**
188     * Get the response. See getResultData for the public method.
189     * @since 2015.02
190     * @throws TranslationWebServiceException
191     */
192    abstract protected function parseResponse( TranslationQueryResponse $response );
193
194    /* Default implementation */
195
196    /** @var string Name of this webservice. */
197    protected $service;
198    /** @var array */
199    protected $config;
200    /** @var LoggerInterface */
201    protected $logger;
202
203    public function __construct( string $service, array $config ) {
204        $this->service = $service;
205        $this->config = $config;
206    }
207
208    /**
209     * Test whether given language pair is supported by the service.
210     * @since 2015.12
211     * @throws TranslationWebServiceConfigurationException
212     */
213    public function isSupportedLanguagePair( string $sourceLanguage, string $targetLanguage ): bool {
214        $pairs = $this->getSupportedLanguagePairs();
215        $from = $this->mapCode( $sourceLanguage );
216        $to = $this->mapCode( $targetLanguage );
217
218        return isset( $pairs[$from][$to] );
219    }
220
221    /**
222     * @see self::doPairs
223     * @throws TranslationWebServiceConfigurationException
224     */
225    protected function getSupportedLanguagePairs(): array {
226        $cache = ObjectCache::getInstance( CACHE_ANYTHING );
227
228        return $cache->getWithSetCallback(
229            $cache->makeKey( 'translate-tmsug-pairs-' . $this->service ),
230            $cache::TTL_DAY,
231            function ( &$ttl ) use ( $cache ) {
232                try {
233                    $pairs = $this->doPairs();
234                } catch ( Exception $e ) {
235                    $pairs = [];
236                    $this->reportTranslationServiceFailure( $e->getMessage() );
237                    $ttl = $cache::TTL_UNCACHEABLE;
238                }
239
240                return $pairs;
241            }
242        );
243    }
244
245    /**
246     * Some mangling that tries to keep some parts of the message unmangled
247     * by the translation service. Most of them support either class=notranslate
248     * or translate=no.
249     */
250    protected function wrapUntranslatable( string $text ): string {
251        $text = str_replace( "\n", '!N!', $text );
252        $pattern = '~%[^% ]+%|\$\d|{VAR:[^}]+}|{?{(PLURAL|GRAMMAR|GENDER):[^|]+\||%(\d\$)?[sd]~';
253        $wrap = '<span class="notranslate" translate="no">\0</span>';
254        return preg_replace( $pattern, $wrap, $text );
255    }
256
257    /** Undo the hopyfully untouched mangling done by wrapUntranslatable. */
258    protected function unwrapUntranslatable( string $text ): string {
259        $text = str_replace( '!N!', "\n", $text );
260        $pattern = '~<span class="notranslate" translate="no">(.*?)</span>~';
261        return preg_replace( $pattern, '\1', $text );
262    }
263
264    /* Failure handling and suspending */
265
266    public function setLogger( LoggerInterface $logger ): void {
267        $this->logger = $logger;
268    }
269
270    /**
271     * @var int How many failures during failure period need to happen to
272     * consider the service being temporarily off-line.
273     */
274    protected $serviceFailureCount = 5;
275    /**
276     * @var int How long after the last detected failure we clear the status and
277     * try again.
278     */
279    protected $serviceFailurePeriod = 900;
280
281    /** Checks whether the service has exceeded failure count */
282    public function checkTranslationServiceFailure(): bool {
283        $service = $this->service;
284        $cache = ObjectCache::getInstance( CACHE_ANYTHING );
285
286        $key = $cache->makeKey( "translate-service-$service" );
287        $value = $cache->get( $key );
288        if ( !is_string( $value ) ) {
289            return false;
290        }
291
292        [ $count, $failed ] = explode( '|', $value, 2 );
293        $count = (int)$count;
294        $failed = (int)$failed;
295        $now = (int)wfTimestamp();
296
297        if ( $failed + ( 2 * $this->serviceFailurePeriod ) < $now ) {
298            if ( $count >= $this->serviceFailureCount ) {
299                $this->logger->warning( "Translation service $service (was) restored" );
300            }
301            $cache->delete( $key );
302
303            return false;
304        } elseif ( $failed + $this->serviceFailurePeriod < $now ) {
305            /* We are in suspicious mode and one failure is enough to update
306             * failed timestamp. If the service works however, let's use it.
307             * Previous failures are forgotten after another failure period
308             * has passed */
309            return false;
310        }
311
312        // Check the failure count against the limit
313        return $count >= $this->serviceFailureCount;
314    }
315
316    /** Increases the failure count for this service */
317    protected function reportTranslationServiceFailure( string $msg ): void {
318        $service = $this->service;
319        $this->logger->warning( "Translation service $service problem: $msg" );
320
321        $cache = ObjectCache::getInstance( CACHE_ANYTHING );
322        $key = $cache->makeKey( "translate-service-$service" );
323
324        $value = $cache->get( $key );
325        if ( !is_string( $value ) ) {
326            $count = 0;
327        } else {
328            [ $count, ] = explode( '|', $value, 2 );
329        }
330
331        $count++;
332        $failed = wfTimestamp();
333        $cache->set(
334            $key,
335            "$count|$failed",
336            $this->serviceFailurePeriod * 5
337        );
338
339        if ( $count === $this->serviceFailureCount ) {
340            $this->logger->error( "Translation service $service suspended" );
341        } elseif ( $count > $this->serviceFailureCount ) {
342            $this->logger->warning( "Translation service $service still suspended" );
343        }
344    }
345}