Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
98.84% |
85 / 86 |
|
83.33% |
5 / 6 |
CRAP | |
0.00% |
0 / 1 |
FluxxClient | |
98.84% |
85 / 86 |
|
83.33% |
5 / 6 |
21 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
getToken | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
makePostRequest | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
makePostRequestInternal | |
96.97% |
32 / 33 |
|
0.00% |
0 / 1 |
5 | |||
parseResponseJSON | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
requestToken | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
6 |
1 | <?php |
2 | |
3 | declare( strict_types=1 ); |
4 | |
5 | namespace MediaWiki\Extension\WikimediaCampaignEvents\Grants; |
6 | |
7 | use JsonException; |
8 | use MediaWiki\Config\ServiceOptions; |
9 | use MediaWiki\Extension\WikimediaCampaignEvents\Grants\Exception\AuthenticationException; |
10 | use MediaWiki\Extension\WikimediaCampaignEvents\Grants\Exception\FluxxRequestException; |
11 | use MediaWiki\Http\HttpRequestFactory; |
12 | use MediaWiki\MainConfigNames; |
13 | use MWHttpRequest; |
14 | use Psr\Log\LoggerInterface; |
15 | use 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 | */ |
21 | class 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 | } |