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