Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.88% covered (success)
93.88%
92 / 98
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCom_Themes_Api
93.88% covered (success)
93.88%
92 / 98
72.73% covered (warning)
72.73%
8 / 11
39.35
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetch_themes
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 handle_request
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 fetch_all_non_delisted_themes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetch_theme
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 fetch_partner_products
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 fetch_wporg_slugs
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
9.01
 fetch_wporg_listed
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 fetch_wporg_batch
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
 fetch_recommended_themes
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 search_themes
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Class WPCom_Themes_Api.
4 * Retrieves themes from the WordPress.com themes API.
5 *
6 * @package automattic/jetpack-mu-wpcom
7 */
8
9/**
10 * Fetches themes from the WordPress.com themes API.
11 */
12class WPCom_Themes_Api {
13
14    /**
15     * The URL of the WordPress.com themes API.
16     */
17    const WP_COM_THEMES_API_URL = 'https://public-api.wordpress.com/wpcom/v2/themes?_envelope=1&page=1&number=1000';
18
19    /**
20     * The URL of the WordPress.com theme API.
21     */
22    const WP_COM_THEME_API_URL = 'https://public-api.wordpress.com/rest/v1.2/themes/%s?http_envelope=1';
23
24    /**
25     * The URL listing the partner tier, which is the one tier that is sold per theme.
26     */
27    const WP_COM_PARTNER_THEMES_API_URL = 'https://public-api.wordpress.com/rest/v1.2/themes?http_envelope=1&tier=partner&number=500';
28
29    /**
30     * WordPress.org's themes API, which can look several themes up in one request.
31     */
32    const WP_ORG_THEMES_API_URL = 'https://api.wordpress.org/themes/info/1.2/';
33
34    /**
35     * The most themes WordPress.org looks up in one request.
36     */
37    const WP_ORG_BATCH_SIZE = 100;
38
39    /**
40     * Cache handler.
41     *
42     * @var WPCom_Themes_Cache $cache
43     */
44    private WPCom_Themes_Cache $cache;
45
46    /**
47     * Class constructor.
48     *
49     * @param WPCom_Themes_Cache $cache Cache handler.
50     */
51    public function __construct( WPCom_Themes_Cache $cache ) {
52        $this->cache = $cache;
53    }
54
55    /**
56     * Returns an array of themes fetched from the WordPress.com themes API.
57     * Caching is used to avoid fetching the themes on every request.
58     *
59     * @param string $cache_key Key of the cache where the API response will be cached.
60     * @param array  $params    Query params to pass to the API URL.
61     *
62     * @return array<stdClass> An array with all the WPCom themes.
63     */
64    protected function fetch_themes( string $cache_key, array $params = array() ): array {
65        $url = add_query_arg( $params, self::WP_COM_THEMES_API_URL );
66
67        return $this->cache->run_cached(
68            $cache_key,
69            function () use ( $url ) {
70                $body = $this->handle_request( $url );
71
72                // Null, not an empty array: run_cached() stores anything else.
73                return isset( $body->themes ) && is_array( $body->themes ) ? $body->themes : null;
74            }
75        ) ?? array();
76    }
77
78    /**
79     * Fetches the response from the given URL.
80     *
81     * @param string $url URL to fetch.
82     *
83     * @return ?stdClass Response body.
84     */
85    protected function handle_request( string $url ): ?stdClass {
86        $response = wp_remote_get( esc_url_raw( $url ) );
87        if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
88            $body_json = json_decode( wp_remote_retrieve_body( $response ) );
89
90            if ( isset( $body_json->body ) ) {
91                return $body_json->body;
92            }
93        }
94
95        return null;
96    }
97
98    /**
99     * Returns all the WP.com themes.
100     *
101     * @return array<stdClass> An array with all the WPCom themes.
102     */
103    public function fetch_all_non_delisted_themes(): array {
104        return $this->fetch_themes( 'wpcom-themes-all' );
105    }
106
107    /**
108     * Returns the WP.com theme with the given slug.
109     *
110     * @param string $slug Theme slug.
111     *
112     * @return stdClass|null A WPCom theme object or null if not found.
113     */
114    public function fetch_theme( string $slug ): ?stdClass {
115        $url = sprintf( self::WP_COM_THEME_API_URL, $slug );
116
117        $theme = $this->cache->run_cached(
118            'wpcom-themes-' . $slug,
119            fn() => $this->handle_request( $url )
120        );
121
122        if ( ! $theme || isset( $theme->error ) ) {
123            return null;
124        }
125
126        return $theme;
127    }
128
129    /**
130     * Returns the store products each partner theme is sold as, by theme slug.
131     *
132     * The catalog endpoint does not carry `product_details`, so the one tier that is sold per
133     * theme is read again from the endpoint that does. DOTCOM-18927.
134     *
135     * @return array<string, array<stdClass>> Product details, keyed by theme slug.
136     */
137    public function fetch_partner_products(): array {
138        return $this->cache->run_cached(
139            'wpcom-themes-partner-products',
140            function () {
141                $body = $this->handle_request( self::WP_COM_PARTNER_THEMES_API_URL );
142
143                if ( ! isset( $body->themes ) || ! is_array( $body->themes ) ) {
144                    return null;
145                }
146
147                $products = array();
148                foreach ( $body->themes as $theme ) {
149                    if ( isset( $theme->id ) && is_string( $theme->id ) && ! empty( $theme->product_details ) ) {
150                        $products[ $theme->id ] = (array) $theme->product_details;
151                    }
152                }
153
154                return $products;
155            }
156        ) ?? array();
157    }
158
159    /**
160     * Returns which of these WordPress.com themes WordPress.org also lists.
161     *
162     * Themes in the catalog share one daily lookup. Search also returns themes outside it, such as
163     * older default themes, which are looked up together and cached as a set.
164     *
165     * @param string[] $slugs Theme slugs.
166     * @return string[]|null The ones WordPress.org lists, or null when it could not be asked.
167     */
168    public function fetch_wporg_slugs( array $slugs ): ?array {
169        $catalog = array();
170        foreach ( $this->fetch_all_non_delisted_themes() as $theme ) {
171            if ( isset( $theme->id ) && is_string( $theme->id ) ) {
172                $catalog[] = $theme->id;
173            }
174        }
175
176        if ( array() === $catalog ) {
177            return null;
178        }
179
180        $listed = $this->cache->run_cached(
181            'wpcom-themes-wporg-slugs',
182            fn() => $this->fetch_wporg_listed( $catalog )
183        );
184
185        $outside = array_values( array_diff( array_filter( $slugs, 'is_string' ), $catalog ) );
186        if ( null !== $listed && array() !== $outside ) {
187            sort( $outside );
188            $also   = $this->cache->run_cached(
189                'wpcom-themes-wporg-slugs-' . md5( implode( ',', $outside ) ),
190                fn() => $this->fetch_wporg_listed( $outside )
191            );
192            $listed = null === $also ? null : array_merge( $listed, $also );
193        }
194
195        return null === $listed ? null : array_values( array_intersect( $slugs, $listed ) );
196    }
197
198    /**
199     * Asks WordPress.org which of these themes it lists, in batches it accepts.
200     *
201     * @param string[] $slugs Theme slugs.
202     * @return string[]|null The slugs it lists, or null when a batch could not be asked.
203     */
204    protected function fetch_wporg_listed( array $slugs ): ?array {
205        $listed = array();
206        foreach ( array_chunk( $slugs, self::WP_ORG_BATCH_SIZE ) as $batch ) {
207            $found = $this->fetch_wporg_batch( $batch );
208
209            // A partial answer would read as "not on WordPress.org" for the rest.
210            if ( null === $found ) {
211                return null;
212            }
213
214            $listed = array_merge( $listed, $found );
215        }
216
217        return $listed;
218    }
219
220    /**
221     * Asks WordPress.org which of these themes it lists.
222     *
223     * @param string[] $slugs At most WP_ORG_BATCH_SIZE theme slugs.
224     * @return string[]|null The slugs it lists, or null when it could not be asked.
225     */
226    protected function fetch_wporg_batch( array $slugs ): ?array {
227        $url = add_query_arg(
228            array(
229                'action'  => 'theme_information',
230                'request' => array(
231                    'slugs'  => implode( ',', $slugs ),
232                    'fields' => array(
233                        'sections'    => 0,
234                        'description' => 0,
235                        'tags'        => 0,
236                        'ratings'     => 0,
237                    ),
238                ),
239            ),
240            self::WP_ORG_THEMES_API_URL
241        );
242
243        $response = wp_remote_get( $url );
244        $body     = json_decode( (string) wp_remote_retrieve_body( $response ), true );
245
246        if ( 200 !== wp_remote_retrieve_response_code( $response ) || ! is_array( $body ) || isset( $body['error'] ) ) {
247            return null;
248        }
249
250        $listed = array();
251        foreach ( $body as $slug => $theme ) {
252            // A theme it does not list comes back as `{ "error": "Theme not found" }`.
253            if ( is_array( $theme ) && ! isset( $theme['error'] ) ) {
254                $listed[] = (string) $slug;
255            }
256        }
257
258        return $listed;
259    }
260
261    /**
262     * Returns the collection of the recommended WP.com themes.
263     *
264     * @return array<stdClass> An array with all the recommended WPCom themes.
265     */
266    public function fetch_recommended_themes(): array {
267        return $this->fetch_themes(
268            'wpcom-themes-recommended',
269            array( 'collection' => 'recommended' )
270        );
271    }
272
273    /**
274     * Returns the WP.com themes that match the given search term.
275     *
276     * @param string $search Search term.
277     *
278     * @return array<stdClass> An array with all the matching WPCom themes.
279     */
280    public function search_themes( string $search ): array {
281        return $this->fetch_themes(
282            'wpcom-themes-search-' . md5( $search ),
283            array(
284                'search' => $search,
285                'sort'   => 'date',
286            )
287        );
288    }
289}