PluginProbe
Parse.ly / 3.24.2
Parse.ly v3.24.2
3.24.2 3.24.1 3.24.0 3.23.7 3.23.6 3.23.5 3.23.4 3.23.3 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.17.0 3.18.0 3.18.1 3.19.0 3.19.1 3.19.2 3.19.3 3.2.0 3.2.1 3.20.0 3.20.1 3.20.2 All 106 releases
wp-parsely / src / content-helper / common / providers / base-wordpress-provider.tsx

base-wordpress-provider.tsx in Parse.ly 3.24.2, at src/content-helper/common/providers/base-wordpress-provider.tsx

400 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * WordPress dependencies
3 */
4 /* eslint-disable import/named */
5 import apiFetch, { APIFetchOptions } from '@wordpress/api-fetch';
6 import { Post as CorePost, Taxonomy as CoreTaxonomy, User } from '@wordpress/core-data';
7 /* eslint-enable import/named */
8 import { __ } from '@wordpress/i18n';
9 import { addQueryArgs } from '@wordpress/url';
10
11 /**
12 * Internal dependencies
13 */
14 import { ContentHelperError, ContentHelperErrorCode } from '../content-helper-error';
15 import { BaseProvider } from './base-provider';
16
17 /**
18 * Type definition for a taxonomy term.
19 *
20 * The core Taxonomy needs to be extended as the core type is missing a few properties.
21 *
22 * @since 3.18.0
23 */
24 type Taxonomy = CoreTaxonomy & {
25 id: number;
26 taxonomy: string;
27 };
28
29 /**
30 * Extended Post interface to include embedded data.
31 *
32 * @since 3.18.0
33 */
34 export interface Post extends CorePost {
35 _embedded?: {
36 author?: User[];
37 'wp:term'?: Taxonomy[][];
38 'wp:featuredmedia'?: {
39 id: number
40 type: string,
41 title: { rendered: string },
42 featured_media: number,
43 media_type: string,
44 source_url: string,
45 media_details: {
46 width: number,
47 height: number,
48 file: string,
49 sizes: {
50 [ key: string ]: {
51 file: string,
52 width: number,
53 height: number,
54 mime_type: string,
55 source_url: string,
56 }
57 }
58 },
59 }[];
60 };
61 parsely?: {
62 version: string;
63 canonical_url: string;
64 };
65 }
66
67 /**
68 * Type definition for a hydrated post.
69 *
70 * A hydrated post is a post with additional data, already fetched.
71 *
72 * @since 3.18.0
73 */
74 export type HydratedPost = Omit<Post, 'author' | 'categories' | 'tags'> & {
75 link: string;
76 author: User | null;
77 categories: Taxonomy[];
78 tags: Taxonomy[];
79 thumbnail: string;
80 parsely_canonical_url?: string;
81 };
82
83 /**
84 * Type definition for a fetch response.
85 *
86 * @since 3.18.0
87 *
88 * @template T The type of the data to fetch.
89 * @property {number} total_items The total number of items.
90 * @property {number} total_pages The total number of pages.
91 * @property {T} data The fetched data.
92 * @property {Response} response The raw Response object.
93 */
94 export type FetchResponse<T> = {
95 total_items: number;
96 total_pages: number;
97 data: T;
98 response: Response;
99 };
100
101 /**
102 * Structure of the response from the 'utils/post/[post-id]/rest-route' endpoint.
103 *
104 * @since 3.20.5
105 */
106 type PostRestRouteResponse = {
107 data: string;
108 }
109
110 /**
111 * Type definition for query parameters.
112 *
113 * @since 3.18.0
114 */
115 export type QueryParams = Record<string, any> & { // eslint-disable-line @typescript-eslint/no-explicit-any
116 context?: 'view' | 'edit' | 'embed';
117 };
118
119 /**
120 * Base class for all WordPress REST API providers.
121 *
122 * Provides a common interface for fetching data from the WordPress REST API,
123 * with support for cancelling requests.
124 *
125 * @since 3.18.0
126 */
127 export abstract class BaseWordPressProvider extends BaseProvider {
128 /**
129 * Fetches data from the WordPress REST API using apiFetch.
130 *
131 * @since 3.18.0
132 *
133 * @template T The type of the data to fetch.
134 *
135 * @param {APIFetchOptions} options The options to pass to apiFetch.
136 * @param {string?} id The (optional) ID of the request.
137 *
138 * @return {Promise<T>} The fetched data.
139 */
140 protected async apiFetch<T>( options: APIFetchOptions, id?: string ): Promise<FetchResponse<T>> {
141 const { abortController, abortId } = this.getOrCreateController( id );
142 options.signal = abortController.signal;
143
144 // Disable parsing of the response body.
145 options.parse = false;
146
147 try {
148 // Fetch the raw Response object.
149 const response = await apiFetch( { ...options, parse: false } ) as Response;
150
151 // Access headers from the response.
152 const totalItemsHeader = response.headers.get( 'X-WP-Total' );
153 const totalPagesHeader = response.headers.get( 'X-WP-TotalPages' );
154
155 // Parse headers to integers.
156 const totalItems = totalItemsHeader ? parseInt( totalItemsHeader, 10 ) : 0;
157 const totalPages = totalPagesHeader ? parseInt( totalPagesHeader, 10 ) : 0;
158
159 // Parse the response body as JSON.
160 const data: T = await response.json();
161
162 return {
163 total_items: totalItems,
164 total_pages: totalPages,
165 data,
166 response,
167 };
168 } catch ( wpError: any ) { // eslint-disable-line @typescript-eslint/no-explicit-any
169 if ( wpError.name === 'AbortError' ) {
170 return Promise.reject(
171 new ContentHelperError(
172 __( 'The operation was aborted.', 'wp-parsely' ),
173 ContentHelperErrorCode.ParselyAborted,
174 ),
175 );
176 }
177
178 const errorData = await wpError.json();
179 return Promise.reject( new ContentHelperError( errorData.message ?? '', errorData.code, '' ) );
180 } finally {
181 // Clean up the AbortController after the request completes.
182 this.abortControllers.delete( abortId );
183 }
184 }
185
186 /**
187 * Fetches data from the API. Either resolves with the data or rejects with
188 * an error.
189 *
190 * This method is a wrapper around apiFetch() that automatically adds the
191 * AbortController signal.
192 *
193 * @since 3.19.0
194 *
195 * @template T The type of the data to fetch.
196 *
197 * @param {APIFetchOptions} options The options to pass to apiFetch.
198 * @param {string?} id The (optional) ID of the request.
199 *
200 * @return {Promise<T>} The fetched data.
201 */
202 protected async fetch<T>( options: APIFetchOptions, id?: string ): Promise<T> {
203 return ( await this.apiFetch<T>( options, id ) ).data;
204 }
205
206 /**
207 * Hydrates posts with additional embedded data.
208 *
209 * An HydratedPost is a Post with additional data, already fetched from
210 * the REST API.
211 *
212 * This method extracts the author, categories, tags and thumbnail from the
213 * _embedded property of the Post object.
214 *
215 * @since 3.18.0
216 *
217 * @param {Post[]} posts Array of Post objects to hydrate.
218 *
219 * @return {Promise<HydratedPost[]>} Promise resolving to an array of hydrated Post objects.
220 */
221 private async hydratePosts( posts: Post[] ): Promise<HydratedPost[]> {
222 // Map over the posts and extract embedded author and categories.
223 const hydratedPosts = posts.map( ( post ) => {
224 // Initialize empty arrays for the data we want to extract.
225 let categories: Taxonomy[] = [];
226 let tags: Taxonomy[] = [];
227 let thumbnail = '';
228
229 // Extract author data from _embedded.
230 const author = post._embedded?.author ? post._embedded.author[ 0 ] : null;
231
232 // Extract categories and tags data from _embedded.
233 // The first element in the array is categories, the second is tags.
234 if ( post?._embedded?.[ 'wp:term' ] ) {
235 [ categories, tags ] = post._embedded[ 'wp:term' ];
236 }
237
238 // Get the post thumbnail.
239 if ( post?._embedded?.[ 'wp:featuredmedia' ] ) {
240 const featuredMedia = post._embedded[ 'wp:featuredmedia' ]?.[ 0 ];
241 thumbnail = featuredMedia?.media_details?.sizes?.thumbnail?.source_url;
242
243 if ( ! thumbnail ) {
244 thumbnail = featuredMedia?.source_url ?? undefined;
245 }
246 }
247
248 // Get the canonical URL.
249 const canonicalURL = post.parsely?.canonical_url;
250
251 return {
252 ...post,
253 thumbnail,
254 author,
255 categories,
256 tags,
257 parsely_canonical_url: canonicalURL,
258 };
259 } );
260
261 return hydratedPosts;
262 }
263
264 /**
265 * Fetches a list of posts from the REST API and hydrates them with embedded data.
266 *
267 * @since 3.18.0
268 *
269 * @param {QueryParams?} queryParams Optional query parameters.
270 * @param {string?} id The (optional) ID of the request.
271 *
272 * @return {Promise<HydratedPost[]>} The fetched and hydrated posts.
273 */
274 public async getPosts(
275 queryParams: QueryParams = {},
276 id?: string,
277 ): Promise<FetchResponse<HydratedPost[]>> {
278 const restEndpoint = queryParams.rest_endpoint ?? '/wp/v2/posts';
279 const context = queryParams.context ?? 'view';
280
281 const posts = await this.apiFetch<Post[]>( {
282 path: addQueryArgs( restEndpoint, { ...queryParams, _embed: true, context } ),
283 method: 'GET',
284 }, id );
285
286 // Hydrate the fetched posts.
287 const hydratedPosts = await this.hydratePosts( posts.data );
288
289 return {
290 ...posts,
291 data: hydratedPosts,
292 };
293 }
294
295 /**
296 * Fetches a list of pages from the REST API and hydrates them with embedded data.
297 *
298 * @since 3.19.0
299 *
300 * @param {QueryParams?} queryParams Optional query parameters.
301 * @param {string?} id The (optional) ID of the request.
302 *
303 * @return {Promise<FetchResponse<HydratedPost[]>>} The fetched and hydrated pages.
304 */
305 public async getPages(
306 queryParams: QueryParams = {},
307 id?: string,
308 ): Promise<FetchResponse<HydratedPost[]>> {
309 const context = queryParams.context ?? 'view';
310
311 const pages = await this.apiFetch<Post[]>( {
312 path: addQueryArgs( '/wp/v2/pages', { ...queryParams, _embed: true, context } ),
313 method: 'GET',
314 }, id );
315
316 // Hydrate the fetched pages.
317 const hydratedPages = await this.hydratePosts( pages.data );
318
319 return {
320 ...pages,
321 data: hydratedPages,
322 };
323 }
324
325 /**
326 * Fetches a single post by ID from the REST API and hydrates it with embedded data.
327 *
328 * @since 3.18.0
329 *
330 * @param {number} postId The ID of the post to fetch.
331 * @param {string?} id The (optional) ID of the request.
332 *
333 * @return {Promise<HydratedPost>} The fetched and hydrated post.
334 */
335 public async getPost(
336 postId: number,
337 id?: string,
338 ): Promise<HydratedPost> {
339 const context = 'edit';
340
341 let postRestRoute: PostRestRouteResponse;
342
343 try {
344 postRestRoute = await this.fetch<PostRestRouteResponse>( {
345 method: 'GET',
346 path: `/wp-parsely/v2/utils/post/${ postId }/rest-route`,
347 }, id );
348 } catch ( error ) {
349 throw new ContentHelperError(
350 __( "The target post's REST route could not be fetched.", 'wp-parsely' ),
351 ContentHelperErrorCode.UnknownError,
352 );
353 }
354
355 if ( ! postRestRoute || ! postRestRoute.data || '' === postRestRoute.data ) {
356 throw new ContentHelperError(
357 __( "The target post's REST route could not be fetched.", 'wp-parsely' ),
358 ContentHelperErrorCode.UnknownError,
359 );
360 }
361
362 const post = await this.apiFetch<Post>( {
363 path: `${ postRestRoute.data }?_embed&context=${ context }`,
364 method: 'GET',
365 }, id );
366
367 // Hydrate the fetched post.
368 const hydratedPost = ( await this.hydratePosts( [ post.data ] ) )[ 0 ];
369
370 return hydratedPost;
371 }
372
373 /**
374 * Generic method to fetch any REST API endpoint.
375 *
376 * @since 3.18.0
377 *
378 * @template T The type of the data to fetch.
379 *
380 * @param {string} path The REST API path to fetch.
381 * @param {APIFetchOptions?} options Additional options for the request.
382 * @param {string?} id The (optional) ID of the request.
383 *
384 * @return {Promise<T>} The fetched data.
385 */
386 public async fetchFromRestApi<T>(
387 path: string,
388 options: Partial<APIFetchOptions> = {},
389 id?: string,
390 ): Promise<FetchResponse<T>> {
391 const fetchOptions: APIFetchOptions = {
392 path,
393 method: 'GET',
394 ...options,
395 };
396
397 return this.apiFetch<T>( fetchOptions, id );
398 }
399 }
400