import { isRecord, isString } from '@tool-belt/type-predicates' import { URLSearchParams } from 'url' import { WP_Post_Type_Name, WP_REST_API_Application_Password, WP_REST_API_Block, WP_REST_API_Block_Directory_Item, WP_REST_API_Block_Type, WP_REST_API_Rendered_Block, WP_REST_API_Search_Result, WP_REST_API_Settings, WP_REST_API_Status, WP_REST_API_Taxonomy, WP_REST_API_Type, } from 'wp-types' import { END_POINT, END_POINT_PROTECTED, ERROR_MESSAGE, TRASHABLE, } from './constants' import { FetchClient } from './fetch-client' import { AUTH_TYPE, DefaultEndpoint, DefaultEndpointWithRevision, EndpointCreate, EndpointDelete, EndpointDeleteUntrashable, EndpointFind, EndpointFindAll, EndpointFindOnly, EndpointTotal, EndpointUpdate, EndpointUpdateMedia, EndpointUpdatePartial, RenderedBlockDto, WpApiOptions, WPCategory, WPComment, WPMedia, WPPage, WPPlugin, WPPost, WpRestApiContext, WPTag, WPTheme, WPUser, } from './types' import { getDefaultQueryList, getDefaultQuerySingle, getDeleteUri, postCreate, } from './util' export class WpApiClient { protected readonly authHeader?: | { Authorization: string } | { 'X-WP-Nonce': string } protected readonly headers?: Record protected readonly http: FetchClient protected readonly baseUrl: URL constructor( baseUrl: string, protected readonly options: WpApiOptions = { auth: { type: AUTH_TYPE.NONE }, protected: END_POINT_PROTECTED, }, ) { if (options.auth?.type === AUTH_TYPE.BASIC) { const authString = `${options.auth.username}:${options.auth.password}` this.authHeader = { Authorization: `Basic ${Buffer.from(authString).toString( 'base64', )}`, } } if (options.auth?.type === AUTH_TYPE.JWT) this.authHeader = { Authorization: `Bearer ${options.auth.token}`, } if (options.auth?.type === AUTH_TYPE.NONCE) this.authHeader = { 'X-WP-Nonce': options.auth.nonce, } this.baseUrl = new URL(options.restBase ?? 'wp-json', baseUrl) this.headers = options.headers this.http = new FetchClient( this.baseUrl, options.onError, this.headers, this.authHeader, options.protected, options.public, options.auth?.type, ) } // PROTECTED protected createEndpointGet

( endpoint: string, defaultQuery = new URLSearchParams(), ): EndpointFind

{ return async (query?: URLSearchParams | number, ...ids: number[]) => { ids = typeof query === 'number' ? [query, ...ids] : ids query = typeof query === 'number' ? defaultQuery : new URLSearchParams({ ...Object.fromEntries(defaultQuery), ...Object.fromEntries(query ?? defaultQuery), }) if (!ids.length) { return ( (await this.http.get( `${endpoint}/${getDefaultQueryList(query)}`, )) ?? [] ) } else { return Promise.all( ids.map(async postId => this.http.get

( `${endpoint}/${postId}/${getDefaultQuerySingle( query, )}`, ), ), ) } } } protected createEndpointGetAll

( endpoint: string, defaultQuery = new URLSearchParams({ page: '1' }), ): EndpointFindAll

{ return async (query?: URLSearchParams) => { query = new URLSearchParams({ ...Object.fromEntries(defaultQuery), ...Object.fromEntries(query ?? defaultQuery), }) return this.http.getAll

( `${endpoint}/${getDefaultQueryList(query)}`, ) } } protected createEndpointPost

( endpoint: string, ): (body: Partial

, id?: number) => Promise

{ return async (body: Partial

, id = 0) => { if (id) return this.http.post

( `${endpoint}/${id}`, undefined, JSON.stringify( postCreate>({ ...body, }), ), ) else return this.http.post

( `${endpoint}`, undefined, JSON.stringify( postCreate>({ ...body, }), ), ) } } protected createEndpointDelete

( endpoint: string, params?: URLSearchParams, ): EndpointDelete

{ const trashable = this.options.trashable ?? TRASHABLE return async (...ids: number[]) => { if (!ids.length) throw new Error(ERROR_MESSAGE.ID_REQUIRED) return Promise.all( ids.map(id => this.http.delete

( getDeleteUri(endpoint, id, params, trashable), ), ), ) } } protected createEndpointCustomGet( endPoint: string, ): () => Promise { return async (): Promise => { return this.http.get(endPoint) } } protected createEndpointCustomPost( endPoint: string, ): (body: T) => Promise { return async (body: T): Promise => { return this.http.post(endPoint, undefined, JSON.stringify(body)) } } protected createEndpointTotal( endpoint: string, defaultQuery = new URLSearchParams(), ): EndpointTotal { return async () => this.http.getTotal(`${endpoint}/?${defaultQuery.toString()}`) } protected defaultEndpoints

( endpoint: string, defaultParams?: URLSearchParams, ): DefaultEndpoint

{ return { create: this.createEndpointPost

(endpoint), find: this.createEndpointGet

(endpoint, defaultParams), update: this.createEndpointPost

(endpoint), delete: this.createEndpointDelete

(endpoint), dangerouslyFindAll: this.createEndpointGetAll

( endpoint, defaultParams, ), total: this.createEndpointTotal(endpoint, defaultParams), } } protected addPostType

( endpoint: string, withRevisions: true, defaultParams?: URLSearchParams, ): DefaultEndpointWithRevision

protected addPostType

( endpoint: string, withRevisions?: false, defaultParams?: URLSearchParams, ): DefaultEndpoint

protected addPostType

( endpoint: string, withRevisions = false, defaultParams?: URLSearchParams, ): { find: EndpointFind

create: EndpointCreate

delete: EndpointDelete

update: EndpointUpdate

revision?: (postId: number) => { find: EndpointFind

create: EndpointCreate

delete: EndpointDelete

update: EndpointUpdate

} } { return { ...this.defaultEndpoints(endpoint, defaultParams), revision: !withRevisions ? undefined : (postId: number) => ({ ...this.defaultEndpoints( `${endpoint}/${postId}/revisions`, defaultParams, ), }), } } // PUBLIC public async blockType

(): Promise public async blockType

( blockType: WP_Post_Type_Name | string, ): Promise

public async blockType

( blockType?: WP_Post_Type_Name | string, ): Promise

{ return blockType ? this.http.get

(`${END_POINT.BLOCK_TYPES}/${blockType}`) : this.http.get(END_POINT.BLOCK_TYPES) } public async blockDirectory

( term: string, page = 1, perPage = 10, ): Promise { return this.http.get( `${END_POINT.BLOCK_DIRECTORY}?${new URLSearchParams({ page: String(page), per_page: String(perPage), term, }).toString()}`, ) } public comment

(): DefaultEndpoint

{ return this.addPostType

(END_POINT.COMMENTS, false) } public media

(): { find: EndpointFind

create: ( fileName: string, file: Buffer, mimeType?: string, data?: Partial

, caption?: string, ) => Promise

delete: EndpointDeleteUntrashable

update: EndpointUpdateMedia

} { const find = this.createEndpointGet

(END_POINT.MEDIA) const update = >( this.createEndpointPost

(END_POINT.MEDIA) ) /** * @param {string} fileName Must include the file extension * @param {Buffer} file Takes a `Buffer` as input * @param {string} mimeType E.g.: `image/jpeg` * @param {WPMedia} data Optional, populates media library item with a second request * */ const create = async ( fileName: string, file: Buffer, mimeType = 'image/jpeg', data?: Partial

, ): Promise

=> { if (!fileName.includes('.')) throw new Error( ERROR_MESSAGE.INVALID_FILENAME.replace( '%fileName%', fileName, ), ) const headers = { 'Content-Disposition': `attachment; filename="${fileName}"`, 'Content-Type': mimeType, } const result = await this.http.post

( END_POINT.MEDIA, headers, file, ) if (data) return >( update( & { caption?: string }>data, result.id, ) ) return result } const deleteOne = >( this.createEndpointDelete

( END_POINT.MEDIA, new URLSearchParams({}), ) ) return { find, create, delete: deleteOne, update, } } public page

(): DefaultEndpointWithRevision

{ return this.addPostType

(END_POINT.PAGES, true) } public plugin

(): { create: (plugin: string, status?: 'active' | 'inactive') => Promise

find: (plugin?: string) => Promise update: ( plugin: string, status?: 'active' | 'inactive', context?: WpRestApiContext, ) => Promise

delete: (plugin: string) => Promise

} { return { create: async (plugin: string, status = 'inactive') => this.http.post

( END_POINT.PLUGINS, undefined, JSON.stringify({ slug: plugin, status, }), ), find: async (plugin = '') => plugin ? [await this.http.get

(`${END_POINT.PLUGINS}/${plugin}`)] : this.http.get(`${END_POINT.PLUGINS}`), update: async ( plugin: string, status: 'active' | 'inactive' = 'inactive', context: WpRestApiContext = 'view', ) => this.http.post

( `${END_POINT.PLUGINS}/${plugin}?status=${status}&context=${context}`, ), delete: async (plugin: string) => this.http.delete

(`${END_POINT.PLUGINS}/${plugin}`), } } public post

(): DefaultEndpointWithRevision

{ return this.addPostType

(END_POINT.POSTS, true) } public postCategory

(): DefaultEndpoint

{ const deleteOne = this.createEndpointDelete

( END_POINT.CATEGORIES, new URLSearchParams({}), ) return { ...this.addPostType

(END_POINT.CATEGORIES, false), delete: deleteOne, } } public postTag

(): DefaultEndpoint

{ const deleteOne = this.createEndpointDelete

( END_POINT.TAGS, new URLSearchParams({}), ) return { ...this.addPostType

(END_POINT.TAGS, false), delete: deleteOne, } } public async postType

(): Promise public async postType

( postType: WP_Post_Type_Name | string, ): Promise

public async postType

( postType?: WP_Post_Type_Name | string, ): Promise

{ return postType ? this.http.get

(`${END_POINT.TYPES}/type/${postType}`) : this.http.get(END_POINT.TYPES) } public async renderedBlock

( body: RenderedBlockDto, ): Promise

{ return this.http.post

( `${END_POINT.BLOCK_RENDERER}/${body.name}`, undefined, JSON.stringify({ name: body.name, post_id: body.postId, attributes: body.attributes ?? [], context: body.context ?? 'view', }), ) } public reusableBlock

(): DefaultEndpoint

& { autosave: (blockId: number) => { create: EndpointCreate

find: EndpointFind

} } { return { ...this.defaultEndpoints(END_POINT.EDITOR_BLOCKS), autosave: (blockId: number) => { const endpoint = `${END_POINT.EDITOR_BLOCKS}/${blockId}/autosaves` return { create: this.createEndpointPost(endpoint), find: this.createEndpointGet(endpoint), } }, } } public async search( search?: string, params?: Record & Partial<{ context: string page: string per_page: string type: string subtype: string }>, ): Promise { if (search) params = { ...(>params), search } const query = new URLSearchParams(params).toString() return this.http.get(`${END_POINT.SEARCH}/?${query}`) } public siteSettings

(): { find: EndpointFindOnly

update: EndpointUpdatePartial

} { return { find: >( this.createEndpointCustomGet(END_POINT.SETTINGS) ), update: >( this.createEndpointCustomPost, P>(END_POINT.SETTINGS) ), } } public async status

(): Promise public async status

( status: WP_Post_Type_Name | string, ): Promise

public async status

( status?: WP_Post_Type_Name | string, ): Promise

{ return status ? this.http.get

(`${END_POINT.STATUSES}/${status}`) : this.http.get(END_POINT.STATUSES) } public user

(): { find: EndpointFind

findMe: EndpointFindOnly

create: ( body: Partial

& Required<{ email: string; username: string; password: string }>, ) => Promise

update: ( body: Partial

& Required<{ password: string }>, userId: number, ) => Promise

delete: ( reassign: number, ...userIds: number[] ) => Promise<(P | null)[]> deleteMe: (reassign: number) => Promise

} { const findMe = async () => this.http.get

(END_POINT.USERS_ME) const deleteUsers = async (reassign: number, ...userIds: number[]) => { if (!userIds.length) throw new Error( ERROR_MESSAGE.MISSING_REQUIRED_PARAM.replace( '%PARAM%', '"reassign"', ), ) return Promise.all( userIds.map(id => this.http.delete

( `${END_POINT.USERS}/${String(id)}?${new URLSearchParams( { force: String(true), reassign: String(reassign), }, ).toString()}`, ), ), ) } const deleteMe = async (reassign: number) => this.http.delete

( `${END_POINT.USERS_ME}?${new URLSearchParams({ force: String(true), reassign: String(reassign), }).toString()}`, ) return { ...this.addPostType

(END_POINT.USERS), findMe, delete: deleteUsers, deleteMe, } } public async taxonomy

( query: { context?: 'edit' | 'embed' | 'view'; type?: string }, ...slugs: string[] ): Promise public async taxonomy

( ...slugs: string[] ): Promise public async taxonomy

( query?: { context?: 'edit' | 'embed' | 'view'; type?: string } | string, ...slugs: string[] ): Promise { slugs = isString(query) ? [query, ...slugs] : slugs query = isRecord(query) ? query : undefined if (!slugs.length) { return ( (await this.http.get( `${END_POINT.TAXONOMIES}/${getDefaultQueryList( new URLSearchParams(query), )}`, )) ?? [] ) } else { return Promise.all( slugs.map(slug => this.http.get

( `${ END_POINT.TAXONOMIES }/${slug}/${getDefaultQuerySingle( isRecord(query) ? new URLSearchParams(query) : undefined, )}`, ), ), ) } } public async theme

(): Promise { return this.http.get(END_POINT.THEMES) } public applicationPassword() { const find = async ( userId: number, uuids: string[] = [], ): Promise => { const endpoint = `${END_POINT.USERS}/${String(userId)}/${ END_POINT.USER_APPLICATION_PASSWORDS }` if (!uuids.length) { return this.http.get( endpoint, ) } return Promise.all( uuids.map(async uuid => this.http.get( `${endpoint}/${uuid}`, ), ), ) } const create = async ( userId: number, appId: string, name: string, ): Promise> => { const endpoint = `${END_POINT.USERS}/${String(userId)}/${ END_POINT.USER_APPLICATION_PASSWORDS }` return this.http.post>( `${endpoint}?${new URLSearchParams({ app_id: appId, name, }).toString()}`, ) } const update = async ( userId: number, uuid: string, appId?: string, name?: string, ): Promise => { const endpoint = `${END_POINT.USERS}/${String(userId)}/${ END_POINT.USER_APPLICATION_PASSWORDS }/${uuid}` const params = new Map() if (name) params.set('name', name) if (appId) params.set('app_id', appId) return this.http.post( `${endpoint}?${new URLSearchParams(params).toString()}`, ) } const deleteOne = async (userId: number, uuid: string) => { const endpoint = `${END_POINT.USERS}/${String(userId)}/${ END_POINT.USER_APPLICATION_PASSWORDS }/${uuid}` return this.http.delete(endpoint) } return { create, delete: deleteOne, find, update, } } }