Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.84% covered (success)
98.84%
85 / 86
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
FluxxClient
98.84% covered (success)
98.84%
85 / 86
83.33% covered (warning)
83.33%
5 / 6
21
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getToken
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 makePostRequest
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 makePostRequestInternal
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
5
 parseResponseJSON
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 requestToken
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\WikimediaCampaignEvents\Grants;
6
7use JsonException;
8use MediaWiki\Config\ServiceOptions;
9use MediaWiki\Extension\WikimediaCampaignEvents\Grants\Exception\AuthenticationException;
10use MediaWiki\Extension\WikimediaCampaignEvents\Grants\Exception\FluxxRequestException;
11use MediaWiki\Http\HttpRequestFactory;
12use MediaWiki\MainConfigNames;
13use MWHttpRequest;
14use Psr\Log\LoggerInterface;
15use WANObjectCache;
16
17/**
18 * This class implements an interface to the Fluxx API
19 * @see https://wmf.fluxx.io/api/rest/v2/doc (login needed)
20 */
21class FluxxClient {
22    public const SERVICE_NAME = 'WikimediaCampaignEventsFluxxClient';
23
24    public const CONSTRUCTOR_OPTIONS = [
25        self::WIKIMEDIA_CAMPAIGN_EVENTS_FLUXX_OAUTH_URL,
26        self::WIKIMEDIA_CAMPAIGN_EVENTS_FLUXX_BASE_URL,
27        self::WIKIMEDIA_CAMPAIGN_EVENTS_FLUXX_CLIENT_ID,
28        self::WIKIMEDIA_CAMPAIGN_EVENTS_FLUXX_CLIENT_SECRET,
29        MainConfigNames::CopyUploadProxy,
30    ];
31    public const WIKIMEDIA_CAMPAIGN_EVENTS_FLUXX_BASE_URL = 'WikimediaCampaignEventsFluxxBaseUrl';
32    public const WIKIMEDIA_CAMPAIGN_EVENTS_FLUXX_OAUTH_URL = 'WikimediaCampaignEventsFluxxOauthUrl';
33    public const WIKIMEDIA_CAMPAIGN_EVENTS_FLUXX_CLIENT_ID = 'WikimediaCampaignEventsFluxxClientID';
34    public const WIKIMEDIA_CAMPAIGN_EVENTS_FLUXX_CLIENT_SECRET = 'WikimediaCampaignEventsFluxxClientSecret';
35
36    private HttpRequestFactory $httpRequestFactory;
37
38    protected string $fluxxBaseUrl;
39    private string $fluxxOauthUrl;
40    private string $fluxxClientID;
41    private string $fluxxClientSecret;
42    private ?string $requestProxy;
43    protected WANObjectCache $cache;
44    private LoggerInterface $logger;
45
46    public function __construct(
47        HttpRequestFactory $httpRequestFactory,
48        ServiceOptions $options,
49        WANObjectCache $cache,
50        LoggerInterface $logger
51    ) {
52        $this->httpRequestFactory = $httpRequestFactory;
53        $this->cache = $cache;
54        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
55        $this->fluxxOauthUrl = $options->get( self::WIKIMEDIA_CAMPAIGN_EVENTS_FLUXX_OAUTH_URL );
56        $this->fluxxBaseUrl = $options->get( self::WIKIMEDIA_CAMPAIGN_EVENTS_FLUXX_BASE_URL );
57        $this->fluxxClientID = $options->get( self::WIKIMEDIA_CAMPAIGN_EVENTS_FLUXX_CLIENT_ID ) ?? '';
58        $this->fluxxClientSecret = $options->get( self::WIKIMEDIA_CAMPAIGN_EVENTS_FLUXX_CLIENT_SECRET ) ?? '';
59        $this->requestProxy = $options->get( MainConfigNames::CopyUploadProxy ) ?: null;
60        $this->logger = $logger;
61    }
62
63    /**
64     * @return string
65     * @throws AuthenticationException
66     */
67    private function getToken(): string {
68        return $this->cache->getWithSetCallback(
69            $this->cache->makeKey( 'WikimediaCampaignEvents-FluxxToken' ),
70            WANObjectCache::TTL_HOUR,
71            function ( $oldValue, int &$ttl ) {
72                [ 'token' => $token, 'expiry' => $ttl ] = $this->requestToken();
73                return $token;
74            },
75            [ 'pcTTL' => WANObjectCache::TTL_PROC_LONG ]
76        );
77    }
78
79    /**
80     * @param string $endpoint
81     * @param array $postData
82     * @return array The (decoded) response we got from Fluxx.
83     * @throws FluxxRequestException
84     */
85    public function makePostRequest( string $endpoint, array $postData = [] ): array {
86        $headers = [
87            'Content-Type' => 'application/json',
88        ];
89        try {
90            $headers['Authorization'] = 'Bearer ' . $this->getToken();
91        } catch ( AuthenticationException $exception ) {
92            throw new FluxxRequestException( 'Authentication error' );
93        }
94
95        $url = $this->fluxxBaseUrl . $endpoint;
96        return $this->makePostRequestInternal( $url, $postData, $headers );
97    }
98
99    /**
100     * @param string $url
101     * @param array $postData
102     * @param array $headers
103     * @return array The (decoded) response we got from Fluxx.
104     * @throws FluxxRequestException
105     */
106    private function makePostRequestInternal( string $url, array $postData, array $headers ): array {
107        $options = [
108            'method' => 'POST',
109            'timeout' => 5,
110            'postData' => json_encode( $postData )
111        ];
112        if ( $this->requestProxy ) {
113            $options['proxy'] = $this->requestProxy;
114        }
115
116        $req = $this->httpRequestFactory->create(
117            $url,
118            $options,
119            __METHOD__
120        );
121
122        foreach ( $headers as $header => $value ) {
123            $req->setHeader( $header, $value );
124        }
125        $status = $req->execute();
126
127        if ( !$status->isGood() ) {
128            $this->logger->error(
129                'Error in Fluxx api call: {error_status}',
130                [ 'error_status' => $status->__toString() ]
131            );
132            throw new FluxxRequestException( 'Error in Fluxx API call' );
133        }
134
135        $parsedResponse = $this->parseResponseJSON( $req );
136        if ( $parsedResponse === null ) {
137            $this->logger->error(
138                'Error in Fluxx api call: response is not valid JSON',
139                [
140                    'response_status' => $req->getStatus(),
141                    'response_content_type' => $req->getResponseHeader( 'Content-Type' ),
142                    'response_content' => $req->getContent()
143                ]
144            );
145            throw new FluxxRequestException( 'Invalid Fluxx response' );
146        }
147
148        return $parsedResponse;
149    }
150
151    private function parseResponseJSON( MWHttpRequest $request ): ?array {
152        $contentTypeHeader = $request->getResponseHeader( 'Content-Type' );
153        if ( !$contentTypeHeader ) {
154            return null;
155        }
156        $contentType = strtolower( explode( ';', $contentTypeHeader )[0] );
157        if ( $contentType !== 'application/json' ) {
158            return null;
159        }
160
161        try {
162            $parsedResponse = json_decode( $request->getContent(), true, 512, JSON_THROW_ON_ERROR );
163        } catch ( JsonException $_ ) {
164            return null;
165        }
166
167        return is_array( $parsedResponse ) ? $parsedResponse : null;
168    }
169
170    /**
171     * @return array
172     * @phan-return array{token:string,expiry:int}
173     * @throws AuthenticationException
174     */
175    private function requestToken(): array {
176        // Fail fast if we're missing the necessary configuration.
177        if ( $this->fluxxClientID === '' || $this->fluxxClientSecret === '' ) {
178            $this->logger->error( 'Missing configuration for the Fluxx API' );
179            throw new AuthenticationException( 'Fluxx client ID and secret not configured' );
180        }
181
182        $data = [
183            'grant_type' => 'client_credentials',
184            'client_id' => $this->fluxxClientID,
185            'client_secret' => $this->fluxxClientSecret,
186        ];
187        $headers = [
188            'Content-Type' => 'application/json'
189        ];
190
191        try {
192            $responseData = $this->makePostRequestInternal( $this->fluxxOauthUrl, $data, $headers );
193        } catch ( FluxxRequestException $_ ) {
194            throw new AuthenticationException( 'Authentication error' );
195        }
196
197        if ( !isset( $responseData['access_token'] ) || !isset( $responseData['expires_in'] ) ) {
198            throw new AuthenticationException( 'Response does not contain token' );
199        }
200
201        return [ 'token' => $responseData['access_token'], 'expiry' => $responseData['expires_in'] ];
202    }
203}