Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
58.89% covered (warning)
58.89%
106 / 180
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Posts_To_Podcast_Endpoint
58.89% covered (warning)
58.89%
106 / 180
12.50% covered (danger)
12.50%
1 / 8
44.08
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 get_post_publish_promo_dismiss_rest_path
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register_routes
99.01% covered (success)
99.01%
100 / 101
0.00% covered (danger)
0.00%
0 / 1
1
 read_episodes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 dismiss_post_publish_promo
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 read_feature_info
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 enqueue_generation
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
90
 read_job_status
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Local Jetpack-side REST endpoint for the Posts to Podcast feature.
4 *
5 * @package automattic/jetpack-podcast
6 */
7
8namespace Automattic\Jetpack\Podcast;
9
10use Automattic\Jetpack\Connection\Client;
11use Jetpack_Options;
12use WP_Error;
13use WP_REST_Controller;
14use WP_REST_Request;
15use WP_REST_Response;
16use WP_REST_Server;
17
18/**
19 * Forwards `wp.apiFetch` calls from the wp-admin Create tab to the wpcom-side
20 * endpoint as the current user (the upstream endpoint requires user identity).
21 */
22class Posts_To_Podcast_Endpoint extends WP_REST_Controller {
23
24    use Relay_Response;
25
26    const SUPPORTED_LENGTHS                     = array( 'short', 'medium', 'long' );
27    const SUPPORTED_VOICE_PRESETS               = array( 'witty', 'earnest', 'professional' );
28    const REST_NAMESPACE                        = 'wpcom/v2';
29    const REST_BASE                             = 'posts-to-podcast';
30    const POST_PUBLISH_PROMO_DISMISS_REST_ROUTE = 'post-publish-promo/dismiss';
31
32    /**
33     * Whether `init()` has wired its hooks.
34     *
35     * @var bool
36     */
37    private static $initialized = false;
38
39    /**
40     * Wire up routes. Idempotent.
41     */
42    public static function init() {
43        if ( self::$initialized ) {
44            return;
45        }
46        self::$initialized = true;
47
48        $instance = new self();
49        add_action( 'rest_api_init', array( $instance, 'register_routes' ) );
50    }
51
52    /**
53     * Get the REST API path used by apiFetch for post-publish promo dismissal.
54     *
55     * @return string
56     */
57    public static function get_post_publish_promo_dismiss_rest_path() {
58        return '/' . self::REST_NAMESPACE . '/' . self::REST_BASE . '/' . self::POST_PUBLISH_PROMO_DISMISS_REST_ROUTE;
59    }
60
61    /**
62     * Register feature info, enqueue, job-status, and promo dismissal routes.
63     */
64    public function register_routes() {
65        $this->namespace = self::REST_NAMESPACE;
66        $this->rest_base = self::REST_BASE;
67
68        register_rest_route(
69            $this->namespace,
70            $this->rest_base,
71            array(
72                array(
73                    'methods'             => WP_REST_Server::READABLE,
74                    'callback'            => array( $this, 'read_feature_info' ),
75                    'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
76                ),
77                array(
78                    'methods'             => WP_REST_Server::CREATABLE,
79                    'callback'            => array( $this, 'enqueue_generation' ),
80                    'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
81                    'args'                => array(
82                        'window'      => array(
83                            'type'        => 'object',
84                            'required'    => false,
85                            'description' => __( 'Either { unit: days|weeks|months, n: <positive int> } or { from, to } as ISO-8601 dates. Required when postIds is omitted.', 'jetpack-podcast' ),
86                        ),
87                        'postIds'     => array(
88                            'type'        => 'array',
89                            'required'    => false,
90                            'items'       => array( 'type' => 'integer' ),
91                            'maxItems'    => 25,
92                            'description' => __( 'Explicit list of published post IDs to draw from (up to 25). Required when window is omitted.', 'jetpack-podcast' ),
93                        ),
94                        'length'      => array(
95                            'type'        => 'string',
96                            'required'    => true,
97                            'enum'        => self::SUPPORTED_LENGTHS,
98                            'description' => __( 'Length preset id.', 'jetpack-podcast' ),
99                        ),
100                        'voicePreset' => array(
101                            'type'        => 'string',
102                            'required'    => true,
103                            'enum'        => self::SUPPORTED_VOICE_PRESETS,
104                            'description' => __( 'Voice preset id.', 'jetpack-podcast' ),
105                        ),
106                        'prompt'      => array(
107                            'type'        => 'string',
108                            'required'    => false,
109                            'description' => __( 'Optional free-form instructions appended to the generation prompt.', 'jetpack-podcast' ),
110                        ),
111                    ),
112                ),
113            )
114        );
115
116        register_rest_route(
117            $this->namespace,
118            $this->rest_base . '/jobs/(?P<job_id>\d+)',
119            array(
120                array(
121                    'methods'             => WP_REST_Server::READABLE,
122                    'callback'            => array( $this, 'read_job_status' ),
123                    'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
124                    'args'                => array(
125                        'job_id' => array(
126                            'type'     => 'integer',
127                            'required' => true,
128                        ),
129                    ),
130                ),
131            )
132        );
133
134        register_rest_route(
135            $this->namespace,
136            $this->rest_base . '/episodes',
137            array(
138                array(
139                    'methods'             => WP_REST_Server::READABLE,
140                    'callback'            => array( $this, 'read_episodes' ),
141                    'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
142                    'args'                => array(
143                        'page'     => array(
144                            'type'    => 'integer',
145                            'default' => 1,
146                            'minimum' => 1,
147                        ),
148                        'per_page' => array(
149                            'type'    => 'integer',
150                            'default' => 5,
151                            'minimum' => 1,
152                            'maximum' => 50,
153                        ),
154                    ),
155                ),
156            )
157        );
158
159        register_rest_route(
160            $this->namespace,
161            $this->rest_base . '/' . self::POST_PUBLISH_PROMO_DISMISS_REST_ROUTE,
162            array(
163                'methods'             => WP_REST_Server::CREATABLE,
164                'callback'            => array( $this, 'dismiss_post_publish_promo' ),
165                'permission_callback' => function () {
166                    return current_user_can( 'edit_posts' );
167                },
168            )
169        );
170    }
171
172    /**
173     * Return the generated episodes the current user can edit, newest first.
174     * See `Posts_To_Podcast_Helper::get_episodes()` for the envelope shape.
175     *
176     * @param WP_REST_Request $request Full details about the request.
177     *
178     * @return WP_REST_Response
179     */
180    public function read_episodes( WP_REST_Request $request ) {
181        return rest_ensure_response(
182            Posts_To_Podcast_Helper::get_episodes(
183                (int) $request->get_param( 'page' ),
184                (int) $request->get_param( 'per_page' )
185            )
186        );
187    }
188
189    /**
190     * Persist post-publish promo dismissal for the current user and site.
191     *
192     * @return WP_REST_Response
193     */
194    public function dismiss_post_publish_promo() {
195        update_user_option( get_current_user_id(), Create_AI_Podcast_Page::POST_PUBLISH_PROMO_DISMISSED_OPTION, 1 );
196
197        return rest_ensure_response(
198            array(
199                'dismissed' => true,
200            )
201        );
202    }
203
204    /**
205     * Forward GET to the wpcom-side endpoint and return feature info
206     * (remaining credits, plan, supported presets).
207     *
208     * @return WP_REST_Response|WP_Error
209     */
210    public function read_feature_info() {
211        $blog_id = (int) Jetpack_Options::get_option( 'id' );
212        if ( ! $blog_id ) {
213            return new WP_Error( 'site-not-connected', __( 'Site is not connected to WordPress.com.', 'jetpack-podcast' ), array( 'status' => 400 ) );
214        }
215
216        $response = Client::wpcom_json_api_request_as_user(
217            sprintf( '/sites/%d/posts-to-podcast', $blog_id ),
218            '2',
219            array(
220                'method'  => 'GET',
221                'headers' => array( 'content-type' => 'application/json' ),
222                'timeout' => 15,
223            ),
224            null,
225            'wpcom'
226        );
227
228        return $this->relay_response( $response );
229    }
230
231    /**
232     * Forward POST to the wpcom-side endpoint and return the queued job descriptor.
233     *
234     * @param WP_REST_Request $request Full details about the request.
235     *
236     * @return WP_REST_Response|WP_Error
237     */
238    public function enqueue_generation( WP_REST_Request $request ) {
239        $blog_id = (int) Jetpack_Options::get_option( 'id' );
240        if ( ! $blog_id ) {
241            return new WP_Error( 'site-not-connected', __( 'Site is not connected to WordPress.com.', 'jetpack-podcast' ), array( 'status' => 400 ) );
242        }
243
244        $body_payload = array(
245            'length'      => $request->get_param( 'length' ),
246            'voicePreset' => $request->get_param( 'voicePreset' ),
247        );
248
249        $window = $request->get_param( 'window' );
250        if ( null !== $window ) {
251            $body_payload['window'] = $window;
252        }
253
254        $post_ids = $request->get_param( 'postIds' );
255        if ( is_array( $post_ids ) && ! empty( $post_ids ) ) {
256            $body_payload['postIds'] = array_values( array_map( 'intval', $post_ids ) );
257        }
258
259        $prompt = $request->get_param( 'prompt' );
260        if ( is_string( $prompt ) && '' !== $prompt ) {
261            $body_payload['prompt'] = $prompt;
262        }
263
264        if ( ! isset( $body_payload['window'] ) && ! isset( $body_payload['postIds'] ) ) {
265            return new WP_Error( 'missing-source', __( 'One of window or postIds is required.', 'jetpack-podcast' ), array( 'status' => 400 ) );
266        }
267
268        $response = Client::wpcom_json_api_request_as_user(
269            sprintf( '/sites/%d/posts-to-podcast', $blog_id ),
270            '2',
271            array(
272                'method'  => 'POST',
273                'headers' => array( 'content-type' => 'application/json' ),
274                'timeout' => 30,
275            ),
276            wp_json_encode( $body_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
277            'wpcom'
278        );
279
280        return $this->relay_response( $response );
281    }
282
283    /**
284     * Forward GET to the wpcom-side polling endpoint and return the job record.
285     *
286     * @param WP_REST_Request $request Full details about the request.
287     *
288     * @return WP_REST_Response|WP_Error
289     */
290    public function read_job_status( WP_REST_Request $request ) {
291        $blog_id = (int) Jetpack_Options::get_option( 'id' );
292        if ( ! $blog_id ) {
293            return new WP_Error( 'site-not-connected', __( 'Site is not connected to WordPress.com.', 'jetpack-podcast' ), array( 'status' => 400 ) );
294        }
295
296        $job_id = (int) $request['job_id'];
297
298        $response = Client::wpcom_json_api_request_as_user(
299            sprintf( '/sites/%d/posts-to-podcast/jobs/%d', $blog_id, $job_id ),
300            '2',
301            array(
302                'method'  => 'GET',
303                'headers' => array( 'content-type' => 'application/json' ),
304                'timeout' => 15,
305            ),
306            null,
307            'wpcom'
308        );
309
310        return $this->relay_response( $response );
311    }
312}