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