| 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 |
|