Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
82.42% |
150 / 182 |
|
70.00% |
14 / 20 |
CRAP | |
0.00% |
0 / 1 |
ApiClient | |
82.42% |
150 / 182 |
|
70.00% |
14 / 20 |
73.05 | |
0.00% |
0 / 1 |
createAudio | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
updateAudio | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
3.01 | |||
deleteAudio | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
batchDeleteAudio | |
84.85% |
28 / 33 |
|
0.00% |
0 / 1 |
8.22 | |||
getLanguages | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getVoices | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
getVoice | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
updateVoice | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
getProject | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
updateProject | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
getPlayerSettings | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
updatePlayerSettings | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
getVideoSettings | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
callApi | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
5 | |||
buildRequestArgs | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
errorMessageFromResponse | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
deleteErrors | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
addRequestLogs | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
deleteLogs | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
saveErrorMessage | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
4 |
1 | <?php |
2 | |
3 | declare(strict_types=1); |
4 | |
5 | namespace Beyondwords\Wordpress\Core; |
6 | |
7 | use Beyondwords\Wordpress\Core\Environment; |
8 | use Beyondwords\Wordpress\Core\Request; |
9 | use Beyondwords\Wordpress\Component\Post\PostContentUtils; |
10 | use Beyondwords\Wordpress\Component\Post\PostMetaUtils; |
11 | |
12 | /** |
13 | * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) |
14 | **/ |
15 | class ApiClient |
16 | { |
17 | /** |
18 | * Error format. |
19 | * |
20 | * The error format used to display error messages in WordPress admin. |
21 | * |
22 | * @var string |
23 | */ |
24 | public const ERROR_FORMAT = '#%s: %s'; |
25 | |
26 | /** |
27 | * POST /projects/:id/content. |
28 | * |
29 | * @since 3.0.0 |
30 | * @since 5.2.0 Make static. |
31 | * |
32 | * @param int $postId WordPress Post ID |
33 | * |
34 | * @return mixed JSON-decoded response body |
35 | **/ |
36 | public static function createAudio($postId) |
37 | { |
38 | $projectId = PostMetaUtils::getProjectId($postId); |
39 | |
40 | if (! $projectId) { |
41 | return false; |
42 | } |
43 | |
44 | $url = sprintf('%s/projects/%d/content', Environment::getApiUrl(), $projectId); |
45 | |
46 | $body = PostContentUtils::getContentParams($postId); |
47 | |
48 | $request = new Request('POST', $url, $body); |
49 | $response = self::callApi($request, $postId); |
50 | |
51 | return json_decode(wp_remote_retrieve_body($response), true); |
52 | } |
53 | |
54 | /** |
55 | * PUT /projects/:id/content/:id. |
56 | * |
57 | * @since 3.0.0 |
58 | * @since 5.2.0 Make static. |
59 | * |
60 | * @param int $postId WordPress Post ID |
61 | * |
62 | * @return mixed JSON-decoded response body |
63 | **/ |
64 | public static function updateAudio($postId) |
65 | { |
66 | $projectId = PostMetaUtils::getProjectId($postId); |
67 | $contentId = PostMetaUtils::getContentId($postId); |
68 | |
69 | if (! $projectId || ! $contentId) { |
70 | return false; |
71 | } |
72 | |
73 | $url = sprintf('%s/projects/%d/content/%s', Environment::getApiUrl(), $projectId, $contentId); |
74 | |
75 | $body = PostContentUtils::getContentParams($postId); |
76 | |
77 | $request = new Request('PUT', $url, $body); |
78 | $response = self::callApi($request, $postId); |
79 | |
80 | return json_decode(wp_remote_retrieve_body($response), true); |
81 | } |
82 | |
83 | /** |
84 | * DELETE /projects/:id/content/:id. |
85 | * |
86 | * @since 3.0.0 |
87 | * @since 5.2.0 Make static. |
88 | * |
89 | * @param int $postId WordPress Post ID |
90 | * |
91 | * @return mixed JSON-decoded response body |
92 | **/ |
93 | public static function deleteAudio($postId) |
94 | { |
95 | $projectId = PostMetaUtils::getProjectId($postId); |
96 | $contentId = PostMetaUtils::getContentId($postId); |
97 | |
98 | if (! $projectId || ! $contentId) { |
99 | return false; |
100 | } |
101 | |
102 | $url = sprintf('%s/projects/%d/content/%s', Environment::getApiUrl(), $projectId, $contentId); |
103 | |
104 | $request = new Request('DELETE', $url); |
105 | $response = self::callApi($request, $postId); |
106 | $code = wp_remote_retrieve_response_code($response); |
107 | |
108 | // Expect 204 Deleted |
109 | if ($code !== 204) { |
110 | return false; |
111 | } |
112 | |
113 | return json_decode(wp_remote_retrieve_body($response), true); |
114 | } |
115 | |
116 | /** |
117 | * DELETE /projects/:id/content/:id. |
118 | * |
119 | * @since 4.1.0 |
120 | * @since 5.2.0 Make static. |
121 | * @since 5.2.2 Remove sslverify param & increase timeout to 30s for REST API calls. |
122 | * |
123 | * @param int[] $postIds Array of WordPress Post IDs. |
124 | * |
125 | * @throws \Exception |
126 | * @return mixed JSON-decoded response body |
127 | **/ |
128 | public static function batchDeleteAudio($postIds) |
129 | { |
130 | $contentIds = []; |
131 | $updatedPostIds = []; |
132 | |
133 | foreach ($postIds as $postId) { |
134 | $projectId = PostMetaUtils::getProjectId($postId); |
135 | |
136 | if (! $projectId) { |
137 | continue; |
138 | } |
139 | |
140 | $contentId = PostMetaUtils::getContentId($postId); |
141 | |
142 | if (! $contentId) { |
143 | continue; |
144 | } |
145 | |
146 | $contentIds[$projectId][] = $contentId; |
147 | $updatedPostIds[] = $postId; |
148 | } |
149 | |
150 | if (! count($contentIds)) { |
151 | throw new \Exception(esc_html__('None of the selected posts had valid BeyondWords audio data.', 'speechkit')); // phpcs:ignore Generic.Files.LineLength.TooLong |
152 | } |
153 | |
154 | if (count($contentIds) > 1) { |
155 | throw new \Exception(esc_html__('Batch delete can only be performed on audio belonging a single project.', 'speechkit')); // phpcs:ignore Generic.Files.LineLength.TooLong |
156 | } |
157 | |
158 | $projectId = array_key_first($contentIds); |
159 | |
160 | $url = sprintf('%s/projects/%d/content/batch_delete', Environment::getApiUrl(), $projectId); |
161 | |
162 | $body = wp_json_encode(['ids' => $contentIds[$projectId]]); |
163 | |
164 | $request = new Request('POST', $url, $body); |
165 | |
166 | $args = array( |
167 | 'blocking' => true, |
168 | 'body' => $request->getBody(), |
169 | 'headers' => $request->getHeaders(), |
170 | 'method' => $request->getMethod(), |
171 | 'timeout' => 30, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout |
172 | ); |
173 | |
174 | $response = wp_remote_request($request->getUrl(), $args); |
175 | |
176 | // WordPress error performing API call |
177 | if (is_wp_error($response)) { |
178 | throw new \Exception(esc_html($response->get_error_message())); |
179 | } |
180 | |
181 | $responseCode = wp_remote_retrieve_response_code($response); |
182 | |
183 | if ($responseCode <= 299) { |
184 | // An OK response means all content IDs in the request were deleted |
185 | return $updatedPostIds; |
186 | } else { |
187 | // For non-OK responses we do not want to delete any custom fields, |
188 | // so return an empty array |
189 | return []; |
190 | } |
191 | } |
192 | |
193 | /** |
194 | * GET /organization/languages |
195 | * |
196 | * @since 4.0.0 Introduced |
197 | * @since 4.0.2 Prefix endpoint with /organization |
198 | * @since 5.0.0 Cache response using transients |
199 | * @since 5.2.0 Make static. |
200 | * |
201 | * @return mixed JSON-decoded response body |
202 | **/ |
203 | public static function getLanguages() |
204 | { |
205 | $url = sprintf('%s/organization/languages', Environment::getApiUrl()); |
206 | |
207 | $request = new Request('GET', $url); |
208 | $response = self::callApi($request); |
209 | |
210 | return json_decode(wp_remote_retrieve_body($response), true); |
211 | } |
212 | |
213 | /** |
214 | * GET /organization/voices |
215 | * |
216 | * @since 4.0.0 Introduced |
217 | * @since 4.0.2 Prefix endpoint with /organization |
218 | * @since 4.5.1 Check the $languageId param is numeric. |
219 | * @since 5.0.0 Accept numeric language ID or string language code as param. |
220 | * @since 5.2.0 Make static. |
221 | * |
222 | * @param int|string $language BeyondWords Language code or numeric ID |
223 | * |
224 | * @return mixed JSON-decoded response body |
225 | **/ |
226 | public static function getVoices($languageCode) |
227 | { |
228 | $url = sprintf( |
229 | '%s/organization/voices?filter[language.code]=%s&filter[scopes][]=primary&filter[scopes][]=secondary', |
230 | Environment::getApiUrl(), |
231 | urlencode(strval($languageCode)) |
232 | ); |
233 | |
234 | $request = new Request('GET', $url); |
235 | $response = self::callApi($request); |
236 | |
237 | return json_decode(wp_remote_retrieve_body($response), true); |
238 | } |
239 | |
240 | /** |
241 | * Loops though GET /organization/voices, because |
242 | * GET /organization/voice is not available. |
243 | * |
244 | * @since 5.4.0 |
245 | * |
246 | * @param int $voiceId Voice ID. |
247 | * @param int|false $languageCode Language code, optional. |
248 | * |
249 | * @return object|false Voice, or false if not found. |
250 | **/ |
251 | public static function getVoice($voiceId, $languageCode = false) |
252 | { |
253 | if (! $languageCode) { |
254 | $languageCode = get_option('beyondwords_project_language_code'); |
255 | } |
256 | |
257 | $voices = self::getVoices($languageCode); |
258 | |
259 | if (empty($voices)) { |
260 | return false; |
261 | } |
262 | |
263 | return array_column($voices, null, 'id')[$voiceId] ?? false; |
264 | } |
265 | |
266 | /** |
267 | * PUT /voices/:id. |
268 | * |
269 | * @since 5.0.0 |
270 | * @since 5.2.0 Make static. |
271 | * |
272 | * @param array $settings Associative array of voice settings. |
273 | * |
274 | * @return mixed JSON-decoded response body |
275 | **/ |
276 | public static function updateVoice($voiceId, $settings) |
277 | { |
278 | if (empty($voiceId)) { |
279 | return false; |
280 | } |
281 | |
282 | $url = sprintf('%s/organization/voices/%d', Environment::getApiUrl(), $voiceId); |
283 | |
284 | $request = new Request('PUT', $url, wp_json_encode($settings)); |
285 | $response = self::callApi($request); |
286 | |
287 | return json_decode(wp_remote_retrieve_body($response), true); |
288 | } |
289 | |
290 | /** |
291 | * GET /projects/:id. |
292 | * |
293 | * @since 4.0.0 |
294 | * @since 5.0.0 Cache response using transients |
295 | * @since 5.2.0 Make static. |
296 | * |
297 | * @return mixed JSON-decoded response body |
298 | **/ |
299 | public static function getProject() |
300 | { |
301 | $projectId = get_option('beyondwords_project_id'); |
302 | |
303 | if (! $projectId) { |
304 | return false; |
305 | } |
306 | |
307 | $url = sprintf('%s/projects/%d', Environment::getApiUrl(), $projectId); |
308 | |
309 | $request = new Request('GET', $url); |
310 | $response = self::callApi($request); |
311 | |
312 | return json_decode(wp_remote_retrieve_body($response), true); |
313 | } |
314 | |
315 | /** |
316 | * PUT /projects/:id. |
317 | * |
318 | * @since 5.0.0 |
319 | * @since 5.2.0 Make static. |
320 | * |
321 | * @param array $settings Associative array of project settings. |
322 | * |
323 | * @return mixed JSON-decoded response body |
324 | **/ |
325 | public static function updateProject($settings) |
326 | { |
327 | $projectId = get_option('beyondwords_project_id'); |
328 | |
329 | if (! $projectId) { |
330 | return false; |
331 | } |
332 | |
333 | $url = sprintf('%s/projects/%d', Environment::getApiUrl(), $projectId); |
334 | |
335 | $request = new Request('PUT', $url, wp_json_encode($settings)); |
336 | $response = self::callApi($request); |
337 | |
338 | return json_decode(wp_remote_retrieve_body($response), true); |
339 | } |
340 | |
341 | /** |
342 | * GET /projects/:id/player_settings. |
343 | * |
344 | * @since 4.0.0 |
345 | * @since 5.2.0 Make static. |
346 | * |
347 | * @return mixed JSON-decoded response body |
348 | **/ |
349 | public static function getPlayerSettings() |
350 | { |
351 | $projectId = get_option('beyondwords_project_id'); |
352 | |
353 | if (! $projectId) { |
354 | return false; |
355 | } |
356 | |
357 | $url = sprintf('%s/projects/%d/player_settings', Environment::getApiUrl(), $projectId); |
358 | |
359 | $request = new Request('GET', $url); |
360 | $response = self::callApi($request); |
361 | |
362 | return json_decode(wp_remote_retrieve_body($response), true); |
363 | } |
364 | |
365 | /** |
366 | * PUT /projects/:id/player_settings. |
367 | * |
368 | * @since 4.0.0 |
369 | * @since 5.2.0 Make static. |
370 | * |
371 | * @param array $settings Associative array of player settings. |
372 | * |
373 | * @return mixed JSON-decoded response body |
374 | **/ |
375 | public static function updatePlayerSettings($settings) |
376 | { |
377 | $projectId = get_option('beyondwords_project_id'); |
378 | |
379 | if (! $projectId) { |
380 | return false; |
381 | } |
382 | |
383 | $url = sprintf('%s/projects/%d/player_settings', Environment::getApiUrl(), $projectId); |
384 | |
385 | $request = new Request('PUT', $url, wp_json_encode($settings)); |
386 | $response = self::callApi($request); |
387 | |
388 | return json_decode(wp_remote_retrieve_body($response), true); |
389 | } |
390 | |
391 | /** |
392 | * GET /projects/:id/video_settings. |
393 | * |
394 | * @since 4.1.0 |
395 | * @since 5.0.0 Cache response using transients |
396 | * @since 5.2.0 Make static. |
397 | * |
398 | * @param int $projectId BeyondWords Project ID. |
399 | * |
400 | * @return mixed JSON-decoded response body |
401 | **/ |
402 | public static function getVideoSettings($projectId = null) |
403 | { |
404 | if (! $projectId) { |
405 | $projectId = get_option('beyondwords_project_id'); |
406 | |
407 | if (! $projectId) { |
408 | return false; |
409 | } |
410 | } |
411 | |
412 | $url = sprintf('%s/projects/%d/video_settings', Environment::getApiUrl(), (int)$projectId); |
413 | |
414 | $request = new Request('GET', $url); |
415 | $response = self::callApi($request); |
416 | |
417 | return json_decode(wp_remote_retrieve_body($response), true); |
418 | } |
419 | |
420 | /** |
421 | * Call the BeyondWords API backend, logging any errors if the requests for a particular post. |
422 | * |
423 | * @todo investigate whether we can move the logging into a WordPress HTTP filter. |
424 | * |
425 | * @since 3.0.0 |
426 | * @since 3.9.0 Stop saving the speechkit_status post meta - downgrades to plugin v2.x are no longer expected. |
427 | * @since 4.0.0 Removed hash comparison. |
428 | * @since 4.4.0 Handle 204 responses with no body. |
429 | * @since 5.2.0 Make static, return result from wp_remote_request. |
430 | * |
431 | * @param Request $request Request. |
432 | * @param int $postId WordPress Post ID |
433 | * |
434 | * @return array|WP_Error The response array or a WP_Error on failure. See WP_Http::request() for |
435 | * information on return value. |
436 | **/ |
437 | public static function callApi($request, $postId = false) |
438 | { |
439 | // By default we delete the request logs we temporarily store |
440 | $deleteRequestLogs = true; |
441 | |
442 | // Delete existing errors before making this API call |
443 | self::deleteErrors($postId); |
444 | |
445 | $args = self::buildRequestArgs($request); |
446 | |
447 | // Log the request details |
448 | self::addRequestLogs($postId, $request, $args); |
449 | |
450 | // Get response |
451 | $response = wp_remote_request($request->getUrl(), $args); |
452 | $responseCode = wp_remote_retrieve_response_code($response); |
453 | |
454 | // Mark API connection as invalid for 401 (API key may have been revoked) |
455 | if ($responseCode === 401) { |
456 | delete_option('beyondwords_valid_api_connection'); |
457 | } |
458 | |
459 | // Save error messages from WordPress HTTP errors and BeyondWords REST API error responses |
460 | if (is_wp_error($response) || $responseCode > 299) { |
461 | $deleteRequestLogs = false; |
462 | |
463 | $message = self::errorMessageFromResponse($response); |
464 | |
465 | self::saveErrorMessage($postId, $message, $responseCode); |
466 | } |
467 | |
468 | if ($deleteRequestLogs) { |
469 | self::deleteLogs($postId); |
470 | } |
471 | |
472 | return $response; |
473 | } |
474 | |
475 | /** |
476 | * Build the request args for wp_remote_request(). |
477 | * |
478 | * @since 3.0.0 |
479 | * @since 4.0.0 Removed hash comparison and display 403 errors. |
480 | * @since 4.1.0 Introduced. |
481 | * @since 5.2.0 Make static. |
482 | * @since 5.2.2 Remove sslverify param & increase timeout to 30s for REST API calls. |
483 | * |
484 | * @param Request $request BeyondWords Request. |
485 | * |
486 | * @return array WordPress HTTP Request arguments. |
487 | */ |
488 | public static function buildRequestArgs($request) |
489 | { |
490 | return [ |
491 | 'blocking' => true, |
492 | 'body' => $request->getBody(), |
493 | 'headers' => $request->getHeaders(), |
494 | 'method' => $request->getMethod(), |
495 | 'timeout' => 30, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout |
496 | ]; |
497 | } |
498 | |
499 | /** |
500 | * Error message from BeyondWords REST API response. |
501 | * |
502 | * @since 4.1.0 |
503 | * @since 5.2.0 Make static. |
504 | * |
505 | * @param mixed[] $response BeyondWords REST API response. |
506 | * |
507 | * @return string Error message. |
508 | */ |
509 | public static function errorMessageFromResponse($response) |
510 | { |
511 | $body = wp_remote_retrieve_body($response); |
512 | $body = json_decode($body, true); |
513 | |
514 | $message = wp_remote_retrieve_response_message($response); |
515 | |
516 | if (is_array($body)) { |
517 | if (array_key_exists('errors', $body)) { |
518 | $messages = []; |
519 | |
520 | foreach ($body['errors'] as $error) { |
521 | $messages[] = implode(' ', array_values($error)); |
522 | } |
523 | |
524 | $message = implode(', ', $messages); |
525 | } elseif (array_key_exists('message', $body)) { |
526 | $message = $body['message']; |
527 | } |
528 | } |
529 | |
530 | return $message; |
531 | } |
532 | |
533 | /** |
534 | * Deletes errors for a post. |
535 | * |
536 | * @since 4.1.0 Introduced. |
537 | * @since 5.2.0 Make static. |
538 | * |
539 | * @param int $postId WordPress post ID. |
540 | * |
541 | * @return void |
542 | */ |
543 | public static function deleteErrors($postId) |
544 | { |
545 | if (! $postId) { |
546 | return; |
547 | } |
548 | |
549 | // Reset any existing errors before making this API call |
550 | delete_post_meta($postId, 'speechkit_error_message'); |
551 | delete_post_meta($postId, 'beyondwords_error_message'); |
552 | } |
553 | |
554 | /** |
555 | * Log the request details for a post. |
556 | * |
557 | * Logs are removed later if the request was successful and retained if not. |
558 | * |
559 | * @since 4.1.0 Introduced. |
560 | * @since 5.2.0 Make static, use wp_json_encode(), don't save headers. |
561 | * |
562 | * @param int $postId WordPress post ID. |
563 | * @param Request $request BeyondWords Request. |
564 | * @param array $args BeyondWords Request args. |
565 | * |
566 | * @return void |
567 | */ |
568 | public static function addRequestLogs($postId, $request, $args) |
569 | { |
570 | if (! $postId) { |
571 | return; |
572 | } |
573 | |
574 | // Don't store headers in the logs |
575 | unset($args['headers']); |
576 | |
577 | update_post_meta($postId, 'beyondwords_request_url', $request->getUrl()); |
578 | update_post_meta($postId, 'beyondwords_request_args', wp_json_encode($args)); |
579 | } |
580 | |
581 | /** |
582 | * Deletes request/response logs for a post. |
583 | * |
584 | * @since 4.1.0 Introduced. |
585 | * @since 5.2.0 Make static. |
586 | * |
587 | * @param int $postId WordPress post ID. |
588 | * |
589 | * @return void |
590 | */ |
591 | public static function deleteLogs($postId) |
592 | { |
593 | if (! $postId) { |
594 | return; |
595 | } |
596 | |
597 | delete_post_meta($postId, 'beyondwords_request_url'); |
598 | delete_post_meta($postId, 'beyondwords_request_args'); |
599 | } |
600 | |
601 | /** |
602 | * Add an error message for a post. |
603 | * |
604 | * @since 4.1.0 Introduced. |
605 | * @since 4.4.0 Rename from error() to saveErrorMessage(). |
606 | * @since 5.2.0 Make static. |
607 | * |
608 | * @param int $postId WordPress post ID. |
609 | * @param string $message Error message. |
610 | * @param int $code Error code. |
611 | * |
612 | * @return void |
613 | */ |
614 | public static function saveErrorMessage($postId, $message = '', $code = 500) |
615 | { |
616 | if (! $postId) { |
617 | return; |
618 | } |
619 | |
620 | if (! $message) { |
621 | $message = sprintf( |
622 | /* translators: %s is replaced with the support email link */ |
623 | esc_html__('API request error. Please contact %s.', 'speechkit'), |
624 | '<a href="mailto:support@beyondwords.io">support@beyondwords.io</a>' |
625 | ); |
626 | } |
627 | |
628 | if (! $code) { |
629 | $code = 500; |
630 | } |
631 | |
632 | update_post_meta( |
633 | $postId, |
634 | 'beyondwords_error_message', |
635 | sprintf(self::ERROR_FORMAT, (string)$code, $message, $code) |
636 | ); |
637 | } |
638 | } |