Skip to content

Commit

Permalink
Core Data: Add types to createBatch
Browse files Browse the repository at this point in the history
Types the `createBatch` and default batch processor in
preparation for more extensive typing work in `core/data`.
  • Loading branch information
dmsnell committed Mar 15, 2022
1 parent 05b8a7d commit 7fe17d8
Show file tree
Hide file tree
Showing 5 changed files with 197 additions and 117 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,38 @@ import { isFunction, zip } from 'lodash';
*/
import defaultProcessor from './default-processor';

/**
* WordPress dependencies
*/
import type { APIFetchOptions } from '@wordpress/api-fetch';

export interface BatchResponse< Data = unknown > {
output?: Data;
error?: unknown;
}

export type WrappedResponse< ReturnData extends any[] > = {
[ Index in keyof ReturnData ]: BatchResponse< ReturnData[ Index ] >;
};

export interface BatchProcessor {
< ReturnData extends any[] >( requests: APIFetchOptions[] ): Promise<
WrappedResponse< ReturnData >
>;
}

interface APIFetchOptionsProducer {
< Data, Result = Promise< Data > >(
add: ( input: APIFetchOptions ) => Result
): Result;
}

interface BatchItem< Data > {
input: APIFetchOptions;
resolve( value: Data ): void;
reject( error: unknown ): void;
}

/**
* Creates a batch, which can be used to combine multiple API requests into one
* API request using the WordPress batch processing API (/v1/batch).
Expand All @@ -34,26 +66,24 @@ import defaultProcessor from './default-processor';
* }
* ```
*
* @param {Function} [processor] Processor function. Can be used to replace the
* default functionality which is to send an API
* request to /v1/batch. Is given an array of
* inputs and must return a promise that
* resolves to an array of objects containing
* either `output` or `error`.
* @param processor Processor function. Can be used to replace the default
* functionality which is to send an API request to /v1/batch.
* Given an array of api request descriptions and resolves an
* array of objects containing either `output` or `error`.
*/
export default function createBatch( processor = defaultProcessor ) {
export default function createBatch< ReturnData extends any[] >(
processor: BatchProcessor = defaultProcessor
) {
let lastId = 0;
/** @type {Array<{ input: any; resolve: ( value: any ) => void; reject: ( error: any ) => void }>} */
let queue = [];
const pending = new ObservableSet();
let queue: BatchItem< ReturnData[ number ] >[] = [];
const pending = new ObservableSet< number >();

return {
/**
* Adds an input to the batch and returns a promise that is resolved or
* rejected when the input is processed by `batch.run()`.
* Adds an API request to the batch and returns a promise that is
* resolved or rejected when the input is processed by `batch.run()`.
*
* You may also pass a thunk which allows inputs to be added
* asychronously.
* You may also pass a thunk for adding API requests asynchronously.
*
* ```
* // Both are allowed:
Expand All @@ -67,44 +97,49 @@ export default function createBatch( processor = defaultProcessor ) {
* - The thunk returns a promise and that promise resolves, or;
* - The thunk returns a non-promise.
*
* @param {any|Function} inputOrThunk Input to add or thunk to execute.
* @param fetchOptionsProducer Input to add or thunk to execute.
*
* @return {Promise|any} If given an input, returns a promise that
* is resolved or rejected when the batch is
* processed. If given a thunk, returns the return
* value of that thunk.
* @return If given an input, returns a promise that
* is resolved or rejected when the batch is
* processed. If given a thunk, returns the return
* value of that thunk.
*/
add( inputOrThunk ) {
add< Data >(
fetchOptionsProducer: APIFetchOptions | APIFetchOptionsProducer
): Promise< Data > {
const id = ++lastId;
pending.add( id );

const add = ( input ) =>
const queueForResolution = (
fetchOptions: APIFetchOptions
): Promise< Data > =>
new Promise( ( resolve, reject ) => {
queue.push( {
input,
input: fetchOptions,
resolve,
reject,
} );
pending.delete( id );
} );

if ( isFunction( inputOrThunk ) ) {
return Promise.resolve( inputOrThunk( add ) ).finally( () => {
if ( isFunction( fetchOptionsProducer ) ) {
return Promise.resolve(
fetchOptionsProducer< Data >( queueForResolution )
).finally( () => {
pending.delete( id );
} );
}

return add( inputOrThunk );
return queueForResolution( fetchOptionsProducer );
},

/**
* Runs the batch. This calls `batchProcessor` and resolves or rejects
* all promises returned by `add()`.
*
* @return {Promise<boolean>} A promise that resolves to a boolean that is true
* if the processor returned no errors.
* @return Resolves whether the processor succeeded without error.
*/
async run() {
async run(): Promise< boolean > {
if ( pending.size ) {
await new Promise( ( resolve ) => {
const unsubscribe = pending.subscribe( () => {
Expand Down Expand Up @@ -138,13 +173,7 @@ export default function createBatch( processor = defaultProcessor ) {

let isSuccess = true;

for ( const pair of zip( results, queue ) ) {
/** @type {{error?: unknown, output?: unknown}} */
const result = pair[ 0 ];

/** @type {{resolve: (value: any) => void; reject: (error: any) => void} | undefined} */
const queueItem = pair[ 1 ];

for ( const [ result, queueItem ] of zip( results, queue ) ) {
if ( result?.error ) {
queueItem?.reject( result.error );
isSuccess = false;
Expand All @@ -160,29 +189,32 @@ export default function createBatch( processor = defaultProcessor ) {
};
}

class ObservableSet {
constructor( ...args ) {
this.set = new Set( ...args );
class ObservableSet< T > {
private set: Set< T >;
private subscribers: Set< () => void >;

constructor( ...args: T[] ) {
this.set = new Set( args );
this.subscribers = new Set();
}

get size() {
return this.set.size;
}

add( value ) {
add( value: T ) {
this.set.add( value );
this.subscribers.forEach( ( subscriber ) => subscriber() );
return this;
}

delete( value ) {
delete( value: T ) {
const isSuccess = this.set.delete( value );
this.subscribers.forEach( ( subscriber ) => subscriber() );
return isSuccess;
}

subscribe( subscriber ) {
subscribe( subscriber: () => void ) {
this.subscribers.add( subscriber );
return () => {
this.subscribers.delete( subscriber );
Expand Down
76 changes: 0 additions & 76 deletions packages/core-data/src/batch/default-processor.js

This file was deleted.

115 changes: 115 additions & 0 deletions packages/core-data/src/batch/default-processor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* External dependencies
*/
import { chunk } from 'lodash';

/**
* Internal dependencies
*/
import type { BatchResponse, WrappedResponse } from './create-batch';

/**
* WordPress dependencies
*/
import apiFetch, { APIFetchOptions } from '@wordpress/api-fetch';

/**
* Information about the batch framework returned by an OPTIONS request.
*
* See https://make.wordpress.org/core/2020/11/20/rest-api-batch-framework-in-wordpress-5-6/
*/
interface BatchOptionsResponse {
endpoints: [
{
args: {
requests: {
/**
* A plugin may change the number of allowed requests
* to batch at once from the default value of 25.
*/
maxItems: number;
};
};
}
];
}

interface BatchApiResponse< ReturnData extends any[] > {
/** The batch endpoint wraps each individual response in a response envelope */
responses: {
[ Index in keyof ReturnData ]: {
body: ReturnData[ Index ];
status: number;
};
};
failed?: boolean;
}

/**
* Maximum number of requests to place in a single batch request. Obtained by
* sending a preflight OPTIONS request to /batch/v1/.
*/
let maxItems: number | null = null;

/**
* Default batch processor. Sends its input requests to /batch/v1.
*
* @param requests List of API requests to perform at once.
*
* @return Resolves a list whose items correspond to each item in
* the `requests` arg, having `output` as the response to
* the operation if it succeeded and `error` if it failed.
*/
export default async function defaultProcessor< ReturnData extends any[] >(
requests: APIFetchOptions[]
): Promise< WrappedResponse< ReturnData > > {
if ( maxItems === null ) {
const preflightResponse = await apiFetch< BatchOptionsResponse >( {
path: '/batch/v1',
method: 'OPTIONS',
} );
maxItems = preflightResponse.endpoints[ 0 ].args.requests.maxItems;
}

const results = [];

for ( const batchRequests of chunk( requests, maxItems ) ) {
const batchResponse = await apiFetch< BatchApiResponse< ReturnData > >(
{
path: '/batch/v1',
method: 'POST',
data: {
validation: 'require-all-validate',
requests: batchRequests.map( ( request ) => ( {
path: request.path,
body: request.data, // Rename 'data' to 'body'.
method: request.method,
headers: request.headers,
} ) ),
},
}
);

let batchResults: BatchResponse[];

if ( batchResponse.failed ) {
batchResults = batchResponse.responses.map( ( response ) => ( {
error: response?.body,
} ) );
} else {
batchResults = batchResponse.responses.map( ( response ) => {
const result = {} as BatchResponse;
if ( response.status >= 200 && response.status < 300 ) {
result.output = response.body;
} else {
result.error = response.body;
}
return result;
} );
}

results.push( ...batchResults );
}

return results as WrappedResponse< ReturnData >;
}
8 changes: 8 additions & 0 deletions packages/core-data/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"declarationDir": "build-types"
},
"include": [ "src/batch/*.ts" ]
}
Loading

0 comments on commit 7fe17d8

Please sign in to comment.