Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.42% covered (success)
82.42%
150 / 182
70.00% covered (warning)
70.00%
14 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiClient
82.42% covered (success)
82.42%
150 / 182
70.00% covered (warning)
70.00%
14 / 20
73.05
0.00% covered (danger)
0.00%
0 / 1
 createAudio
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 updateAudio
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 deleteAudio
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 batchDeleteAudio
84.85% covered (success)
84.85%
28 / 33
0.00% covered (danger)
0.00%
0 / 1
8.22
 getLanguages
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getVoices
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getVoice
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 updateVoice
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getProject
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 updateProject
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getPlayerSettings
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 updatePlayerSettings
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getVideoSettings
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 callApi
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 buildRequestArgs
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 errorMessageFromResponse
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 deleteErrors
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 addRequestLogs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 deleteLogs
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 saveErrorMessage
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Beyondwords\Wordpress\Core;
6
7use Beyondwords\Wordpress\Core\Environment;
8use Beyondwords\Wordpress\Core\Request;
9use Beyondwords\Wordpress\Component\Post\PostContentUtils;
10use Beyondwords\Wordpress\Component\Post\PostMetaUtils;
11
12/**
13 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
14 **/
15class 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}