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