Repository: prestojs/prestojs (language: python)

Issue:
Endpoint base URL
Currently changing the endpoint base URL requires you to either extend `UrlPattern`:

```js
class GithubUrlPattern extends UrlPattern {
    resolve(...args) {
        const url = super.resolve(...args);
        return `https://api.github.com${url}`;
    }
}
```

or pass `resolveUrl` to `Endpoint` and customise it there.

Adding a global config option would solve the most common use case:

```js
Endpoint.defaultConfig.baseUrl = "https://whatever.com"
```

also updating `MiddlewareUrlConfig` to have a `baseUrl` setting would allow middleware to customise it too


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7172c236..1aaef577 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,9 @@
 * Add [ChoicesWidget](https://prestojs.com/docs/ui-antd/widgets/ChoicesWidget/)
 * Add [parseTime](https://prestojs.com/docs/util/parseTime/) and [formatTime](https://prestojs.com/docs/util/formatTime/)
 * Add [resultPath](https://prestojs.com/docs/rest/paginationMiddleware/) option to `paginationMiddleware`. This allows pagination to be handled on a sub-key of the data.
+* Add [SkipToResponse](https://prestojs.com/docs/rest/SkipToResponse/) for use in middleware to bypass the rest of the middleware chain
+* Add [addFetchStartListener](https://prestojs.com/docs/rest/MiddlewareContext/#method-addFetchStartListener)
+* Add [batchMiddleware](https://prestojs.com/docs/rest/batchMiddleware)
 
 ### Changed
 
@@ -41,6 +44,7 @@
   // after
   paginationMiddleware(PageNumberPaginator, { getPaginationState });
   ```
+* Fix bug in [dedupeInFlightRequestsMiddleware](https://prestojs.com/docs/rest/dedupeInFlightRequestsMiddleware/) that meant deduped calls didn't have all the expected keys in the returned object (eg. `response`).
 
 ## [0.0.16] - 2021-04-26
 
diff --git a/js-packages/@prestojs/rest/src/Endpoint.ts b/js-packages/@prestojs/rest/src/Endpoint.ts
index a07107b2..66570826 100644
--- a/js-packages/@prestojs/rest/src/Endpoint.ts
+++ b/js-packages/@prestojs/rest/src/Endpoint.ts
@@ -46,13 +46,27 @@ export type EndpointOptions<ReturnT> = ExecuteInitOptions & {
      * A function to resolve the URL. It is passed the URL pattern object, any
      * arguments for the URL and any query string parameters.
      *
+     * `this` is bound to the `Endpoint`.
+     *
      * If not provided defaults to:
      *
      * ```js
-     * urlPattern.resolve(urlArgs, { query });
+     * function defaultResolveUrl(urlPattern, urlArgs, query, baseUrl) {
+     *      if (baseUrl[baseUrl.length - 1] === '/') {
+     *          baseUrl = baseUrl.slice(0, -1);
+     *      }
+     *      return baseUrl + this.urlPattern.resolve(urlArgs, { query });
+     *  }
      * ```
      */
     resolveUrl?: (urlPattern: UrlPattern, urlArgs?: Record<string, any>, query?: Query) => string;
+    /**
+     * Base URL to use. This is prepended to the return value of `urlPattern.resolve(...)` and can be used
+     * to change the call to occur to a different domain.
+     *
+     * If not specified defaults to [Endpoint.defaultConfig.baseUrl](doc:Endpoint#static-var-defaultConfig)
+     */
+    baseUrl?: string;
     /**
      * Middleware to apply for this endpoint. By default `getMiddleware` concatenates this with the global
      * [Endpoint.defaultConfig.middleware](doc:Endpoint#static-var-defaultConfig)
@@ -117,7 +131,53 @@ export type ExecuteReturnVal<T> = {
  */
 export type EndpointExecuteOptions = ExecuteInitOptions & UrlResolveOptions;
 
-export type MiddlewareContext<T> = {
+/**
+ * This can be passed to `next` in middleware to skip all subsequent middleware in the chain, the call
+ * to `fetch` and resolve instead to the `promise` passed in.
+ *
+ * This is useful for middleware like [dedupeInFlightRequestsMiddleware](doc:dedupeInFlightRequestsMiddleware) where
+ * if a duplicate request is detected it just needs to wait for the original request to finish and return the
+ * same Response object.
+ *
+ * It can also be used to change how `fetch` is performed (`batchMiddleware` uses this).
+ *
+ * For example this just calls a completely different URL and uses the response object from that:
+ *
+ * ```js
+ * function customFetchMiddleware(next, urlConfig, requestInit, context) {
+ *   return next(new SkipToResponse(
+ *       return fetch('/somewhere-else/');
+ *   ));
+ * }
+ * ```
+ *
+ * Note that this completely bypasses any subsequent middleware in the chain (including response handling) so any
+ * security-related middleware should come before this.
+ *
+ * @extract-docs
+ * @menu-group Endpoint
+ */
+export class SkipToResponse {
+    promise: Promise<Response>;
+
+    /**
+     * @param promise The promise to resolve to instead of calling `fetch`.
+     */
+    constructor(promise: Promise<Response>) {
+        this.promise = promise;
+    }
+}
+
+// Symbol attached to a `Response` to store the decoded body. This is used to avoid decoding the response
+// more than once (which is an error).
+const DECODED_BODY = Symbol.for('@prestojs/Endpoint.DECODED_BODY');
+
+/**
+ * An instance of this is passed as the fourth parameter to middleware functions.
+ * @extract-docs
+ * @menu-group Endpoint
+ */
+export class MiddlewareContext<T> {
     /**
      * Function to re-execute the Endpoint. Accepts no arguments - it replays the request with identical arguments
      * as the first time. This will go through the full middleware cycle again. This is useful for middleware
@@ -142,7 +202,69 @@ export type MiddlewareContext<T> = {
      * This contains the `url`, `response`, `decodedBody` and `result`.
      */
     lastState: null | MiddlewareNextReturn<T>;
-};
+
+    /**
+     * Should not be instantiated manually. This is created by `Endpoint` as part of processing a request.
+     */
+    constructor(options: {
+        execute: () => Promise<T>;
+        executeOptions: EndpointExecuteOptions;
+        requestInit: EndpointRequestInit;
+        endpoint: Endpoint;
+        lastState: null | MiddlewareNextReturn<T>;
+    }) {
+        this.execute = options.execute;
+        this.executeOptions = options.executeOptions;
+        this.requestInit = options.requestInit;
+        this.endpoint = options.endpoint;
+        this.lastState = options.lastState;
+    }
+
+    private _fetchStartCallbacks: ((fetchPromise: Promise<Response>) => void)[] = [];
+
+    /**
+     * Mark a `Response` as decoded.
+     *
+     * You likely don't need this; use `decodeBody` on `Endpoint` instead. This is useful in cases
+     * where you want to bypass the normal `Endpoint` `decodeBody` functionality (for example in
+     * `batchMiddleware`).
+     *
+     * @param response The response object returned from `fetch`.
+     * @param decodedBody The decoded body extracted from `response`.
+     */
+    markResponseDecoded(response: Response, decodedBody: Promise<T>): void {
+        response[DECODED_BODY] = decodedBody;
+    }
+
+    /**
+     * Listen for the start of the call to `fetch`. The callback will be called immediately
+     * after `fetch` is called and is passed the `Promise` returned by `fetch`.
+     *
+     * This is useful for middleware like [dedupeInFlightRequestsMiddleware](doc:dedupeInFlightRequestsMiddleware)
+     * that need access to the `fetch` `Promise` directly. It can then cache the promise and subsequently pass it to
+     * [SkipToResponse](doc:SkipToResponse) to make other calls to an `Endpoint` use the same `fetch` `Response`.
+     *
+     * @param callback The callback that will be called when fetch starts. It will be passed the `Promise` returned by `fetch`
+     */
+    addFetchStartListener(callback: (fetchPromise: Promise<Response>) => void): () => void {
+        this._fetchStartCallbacks.push(callback);
+        return (): void => {
+            const index = this._fetchStartCallbacks.indexOf(callback);
+            if (index !== -1) {
+                this._fetchStartCallbacks.splice(index, 1);
+            }
+        };
+    }
+
+    /**
+     * Called internally by `Endpoint` to notify when `fetch` has started.
+     *
+     * Should not be called elsewhere.
+     */
+    notifyFetchStart(fetchPromise: Promise<Response>): void {
+        this._fetchStartCallbacks.forEach(cb => cb(fetchPromise));
+    }
+}
 
 export type MiddlewareNextReturn<ReturnT> = {
     /**
@@ -176,25 +298,29 @@ export type MiddlewareUrlConfig = {
     pattern: UrlPattern;
     args: Record<string, any>;
     query: Query;
+    baseUrl: string;
 };
 
 /**
- * @param next The next function in the middleware chain. Must be passed the `url` and `requestInit` objects.
+ * @param next The next function in the middleware chain. Must be passed the `url` and `requestInit` objects. Alternatively
+ * you can pass an instance of [SkipToResponse](doc:SkipToResponse) instead of `url` and `requestInit`.
  * @param urlConfig The URL config
  * @param requestInit See [fetch parameters](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters)
  * @param context The context for the current execute. This gives you access to the original options and a function to re-execute the command.
  * @returns Returns the value from `fetch` after it has been transformed by each middleware further down the chain
  */
 export type MiddlewareFunction<T> = (
-    next: (
-        urlConfig: MiddlewareUrlConfig,
-        requestInit: RequestInit
-    ) => Promise<MiddlewareNextReturn<T>>,
+    next: MiddlewareNextFunction<T>,
     urlConfig: MiddlewareUrlConfig,
     requestInit: EndpointRequestInit,
     context: MiddlewareContext<T>
 ) => MiddlewareReturn<T>;
 
+type MiddlewareNextFunction<T> = {
+    (urlConfig: MiddlewareUrlConfig, requestInit: RequestInit): Promise<MiddlewareNextReturn<T>>;
+    (skipToResponse: SkipToResponse): Promise<MiddlewareNextReturn<T>>;
+};
+
 /**
  * Object form of middleware. This allows more control over what the middleware can do. Specifically allows middleware
  * to define how it interacts with `Endpoint.prepare` and allows modifications to the `Endpoint` via `init`.
@@ -234,6 +360,13 @@ type DefaultConfig<ReturnT = any> = {
      * by the endpoint specific middleware.
      */
     getMiddleware: (middleware: Middleware<ReturnT>[], endpoint: Endpoint) => Middleware<ReturnT>[];
+    /**
+     * Base to use for all urls. This can be used to change all URL's to be on a different origin.
+     *
+     * This can also be customised by middleware by changing `urlConfig.baseUrl` (defaults to this setting) or
+     * in each `Endpoint` by passing `baseUrl` in options.
+     */
+    baseUrl: string;
 };
 
 /**
@@ -356,7 +489,9 @@ export class ApiError extends Error {
  * Otherwise Response object itself is returned
  * @param response
  */
-function defaultDecodeBody(response: Response): Response | Record<string, any> | string | null {
+export function defaultDecodeBody(
+    response: Response
+): Response | Record<string, any> | string | null {
     const contentType = response.headers.get('Content-Type');
     const emptyCodes = [204, 205];
 
@@ -379,10 +514,14 @@ function defaultDecodeBody(response: Response): Response | Record<string, any> |
 
 function defaultResolveUrl(
     urlPattern: UrlPattern,
-    urlArgs?: Record<string, any>,
-    query?: Query
+    urlArgs: Record<string, any>,
+    query: Query | undefined,
+    baseUrl: string
 ): string {
-    return this.urlPattern.resolve(urlArgs, { query });
+    if (baseUrl[baseUrl.length - 1] === '/') {
+        baseUrl = baseUrl.slice(0, -1);
+    }
+    return baseUrl + this.urlPattern.resolve(urlArgs, { query });
 }
 
 /**
@@ -555,7 +694,7 @@ function isEqualPrepareKey(a: ExecuteInitOptions, b: ExecuteInitOptions): boolea
  * in any way (the first example above). `result` contains the value returned from any middleware
  * that handled the response before this one or otherwise `decodedBody` for the first middleware.
  *
- * The context object can be used to retrieve the original options from the [Endpoint.execute](doc:Endpoint#method-execute)
+ * The [context object](doc:MiddlewareContext) can be used to retrieve the original options from the [Endpoint.execute](doc:Endpoint#method-execute)
  * call and re-execute the command. This is useful for middleware that may replay a request after an initial failure,
  * eg. if user isn't authenticated on initial attempt.
  *
@@ -611,6 +750,17 @@ function isEqualPrepareKey(a: ExecuteInitOptions, b: ExecuteInitOptions): boolea
  *   to apply its changes to the options used (eg. change URL etc) such that `Endpoint` correctly caches the call.
  * * `process` - Process the middleware. This behaves the same as the function form described above.
  *
+ * ### Advanced
+ *
+ * For advanced use cases there's some additional hooks available.
+ *
+ * * [addFetchStartListener](doc:MiddlewareContext#method-addFetchStartListener) - This allows middleware to be notified
+ *   when the call to `fetch` starts and get access to the `Promise`. [dedupeInFlightRequestsMiddleware](doc:dedupeInFlightRequestsMiddleware)
+ *   uses this to cache an in flight request and return the same response for duplicate calls using [SkipToResponse](doc:SkipToResponse)
+ * * [SkipToResponse](doc:SkipToResponse) - This allows middleware to skip the rest of the middleware chain and the call
+ *   to `fetch`. Instead it is passed a promise that resolves to `Response` and this is used instead of doing the call
+ *   to `fetch` for you.
+ *
  * @menu-group Endpoint
  * @extract-docs
  */
@@ -629,14 +779,33 @@ export default class Endpoint<ReturnT = any> {
     urlPattern: UrlPattern;
     public requestInit: ExecuteInitOptions;
     private urlCache: Map<string, Map<{}, PreparedAction>>;
-    private decodeBody: (res: Response) => any;
-    private resolveUrl: (
+    public decodeBody: (res: Response) => any;
+    public resolveUrl: (
         urlPattern: UrlPattern,
-        urlArgs?: Record<string, any>,
-        query?: Query
+        urlArgs: Record<string, any>,
+        query: Query | undefined,
+        baseUrl: string
     ) => string;
     public middleware: Middleware<ReturnT>[];
 
+    private _baseUrl?: string;
+
+    /**
+     * The base URL to use for this endpoint. This is prepended to the URL returned from `urlPattern.resolve`.
+     *
+     * If not specified then it defaults to [Endpoint.defaultConfig.baseUrl](doc:Endpoint#static-var-defaultConfig).
+     *
+     * Note that middleware can override this as well.
+     */
+    get baseUrl(): string {
+        // This is a getter so that we can read `defaultConfig` when it's executing rather than when Endpoint
+        // is instantiated (ie. you can change `baseUrl` after endpoints have been created.
+        if (this._baseUrl == null) {
+            return Endpoint.defaultConfig.baseUrl;
+        }
+        return this._baseUrl;
+    }
+
     /**
      * @param urlPattern The [UrlPattern](doc:UrlPattern) to use to resolve the URL for this endpoint
      */
@@ -644,11 +813,13 @@ export default class Endpoint<ReturnT = any> {
         const {
             decodeBody = defaultDecodeBody,
             resolveUrl = defaultResolveUrl,
+            baseUrl,
             middleware = [],
             getMiddleware = Endpoint.defaultConfig.getMiddleware,
             ...requestInit
         } = options;
 
+        this._baseUrl = baseUrl;
         this.urlPattern = urlPattern;
         this.urlCache = new Map();
         this.decodeBody = decodeBody;
@@ -683,7 +854,7 @@ export default class Endpoint<ReturnT = any> {
             }
         }
         const { urlArgs = {}, query, ...init } = options;
-        const url = this.resolveUrl(this.urlPattern, urlArgs, query);
+        const url = this.resolveUrl(this.urlPattern, urlArgs, query, this.baseUrl);
         let cache = this.urlCache.get(url);
         if (!cache) {
             cache = new Map();
@@ -738,6 +909,7 @@ export default class Endpoint<ReturnT = any> {
             pattern: this.urlPattern,
             args: urlArgs,
             query: query,
+            baseUrl: this.baseUrl,
         };
 
         const cls = Object.getPrototypeOf(this).constructor;
@@ -751,58 +923,75 @@ export default class Endpoint<ReturnT = any> {
             requestInit,
             result: null,
         };
-        const runFetch = (url: string, requestInit: RequestInit): Promise<ReturnT> =>
-            fetch(url, requestInit)
-                .then(async res => {
-                    returnVal.response = res;
-                    if (!res.ok) {
-                        returnVal.decodedBody = await this.decodeBody(res);
-                        throw new ApiError(res.status, res.statusText, returnVal.decodedBody);
-                    }
-                    return res;
-                })
-                .then(async res => (returnVal.decodedBody = await this.decodeBody(res)));
+
         const executeWithMiddleware = async (): Promise<ReturnT> => {
             const middleware = [...this.middleware];
             let lastMiddleware;
-            const middlewareContext: MiddlewareContext<ReturnT> = {
+            const middlewareContext = new MiddlewareContext<ReturnT>({
                 execute: executeWithMiddleware,
                 executeOptions: options,
                 requestInit,
                 endpoint: this,
                 lastState: null,
-            };
+            });
             let lastMiddlewareReturn: MiddlewareNextReturn<ReturnT>;
             const next = async (
-                urlConfig: MiddlewareUrlConfig,
-                requestInit: EndpointRequestInit
+                urlConfigOrSkipToResponse: MiddlewareUrlConfig | SkipToResponse,
+                requestInit?: EndpointRequestInit
             ): Promise<MiddlewareNextReturn<ReturnT>> => {
-                let errors: string[] = [];
-                if (typeof urlConfig !== 'object') {
-                    errors.push(
-                        `'urlConfig' arg from middleware was not an object, received: ${urlConfig}`
-                    );
-                }
-                if (!requestInit || typeof requestInit != 'object') {
-                    errors.push(
-                        `'requestInit' arg from middleware was not an object, received: ${requestInit}`
-                    );
-                }
-                if (errors.length > 0) {
-                    const err = errors.join('\n');
-                    throw new Error(
-                        `Bad middleware implementation; invalid arguments\n\n${err}\n\nOccurred in middleware:\n\n${lastMiddleware}`
-                    );
+                const skipToResponse =
+                    urlConfigOrSkipToResponse instanceof SkipToResponse
+                        ? urlConfigOrSkipToResponse
+                        : false;
+                if (!skipToResponse) {
+                    let errors: string[] = [];
+                    if (typeof urlConfig !== 'object') {
+                        errors.push(
+                            `'urlConfig' arg from middleware was not an object, received: ${urlConfig}`
+                        );
+                    }
+                    if (!requestInit || typeof requestInit != 'object') {
+                        errors.push(
+                            `'requestInit' arg from middleware was not an object, received: ${requestInit}`
+                        );
+                    }
+                    if (errors.length > 0) {
+                        const err = errors.join('\n');
+                        throw new Error(
+                            `Bad middleware implementation; invalid arguments\n\n${err}\n\nOccurred in middleware:\n\n${lastMiddleware}`
+                        );
+                    }
                 }
                 const nextMiddleware = middleware.shift();
-                if (!nextMiddleware) {
-                    const url = this.resolveUrl(urlConfig.pattern, urlConfig.args, urlConfig.query);
+                if (!nextMiddleware || skipToResponse) {
+                    const url = this.resolveUrl(
+                        urlConfig.pattern,
+                        urlConfig.args,
+                        urlConfig.query,
+                        urlConfig.baseUrl
+                    );
                     returnVal.url = url;
                     returnVal.urlArgs = urlConfig.args;
                     returnVal.query = urlConfig.query;
-                    const result = await runFetch(url, requestInit);
+                    const fetchPromise = skipToResponse
+                        ? skipToResponse.promise
+                        : this.fetch(url, requestInit);
+                    middlewareContext.notifyFetchStart(fetchPromise);
+                    const response = await fetchPromise;
+                    // Make sure body is only decoded once. With `SkipToResponse` we can be dealing
+                    // with the same `Response` multiple times (eg. from dedupeInFlightRequestsMiddleware).
+                    if (!(DECODED_BODY in response)) {
+                        middlewareContext.markResponseDecoded(response, this.decodeBody(response));
+                    }
+                    const decodedBody = await response[DECODED_BODY];
+                    if (!response.ok) {
+                        throw new ApiError(response.status, response.statusText, decodedBody);
+                    }
+                    returnVal.response = response;
+                    returnVal.decodedBody = decodedBody;
+
                     lastMiddlewareReturn = {
-                        result,
+                        result: decodedBody,
                         url,
                         response: returnVal.response,
                         decodedBody: returnVal.decodedBody,
@@ -846,10 +1035,21 @@ export default class Endpoint<ReturnT = any> {
         returnVal.result = await executeWithMiddleware();
         return returnVal as ExecuteReturnVal<ReturnT>;
     }
+
+    /**
+     * Calls [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)
+     *
+     * You can extend `Endpoint` and override this if you need to customise something that isn't
+     * possible with middleware.
+     */
+    public async fetch(url: string, requestInit: RequestInit | undefined): Promise<Response> {
+        return fetch(url, requestInit);
+    }
 }
 
 // Initialisation is not done inline in class as doc extractor doesn't handle object literals well
 Endpoint.defaultConfig = {
+    baseUrl: '',
     requestInit: {},
     middleware: [requestDefaultsMiddleware],
     getMiddleware: <T>(middleware: Middleware<T>[]): Middleware<T>[] => [
diff --git a/js-packages/@prestojs/rest/src/batchMiddleware.ts b/js-packages/@prestojs/rest/src/batchMiddleware.ts
new file mode 100644
index 00000000..c4547d07
--- /dev/null
+++ b/js-packages/@prestojs/rest/src/batchMiddleware.ts
@@ -0,0 +1,313 @@
+import Endpoint, {
+    ApiError,
+    defaultDecodeBody,
+    EndpointRequestInit,
+    MiddlewareContext,
+    MiddlewareNextReturn,
+    MiddlewareObject,
+    MiddlewareReturn,
+    MiddlewareUrlConfig,
+    SkipToResponse,
+} from './Endpoint';
+
+type EndpointCall<T> = {
+    urlConfig: MiddlewareUrlConfig;
+    requestInit: EndpointRequestInit;
+    context: MiddlewareContext<T>;
+    resolvedUrl: string;
+};
+
+// Allow anything as a batch key - we store it in a `Map` so objects are valid. This allows
+// you to use eg. the Endpoint itself as a key (eg. each endpoint forms it's own batch).
+type BatchKey = any;
+
+/**
+ * @expand-properties
+ */
+type BatchMiddlewareOptions<BatchCallReturn, IndividualResult> = {
+    /**
+     * Get the key for this batch. Return false to exclude this call from being batched.
+     *
+     * If not provided a single batch will be generated.
+     *
+     * You can use this function to create different batches. All calls with the same batch key are batched together.
+     */
+    getBatchKey?: (call: EndpointCall<IndividualResult>) => false | BatchKey;
+    /**
+     * Execute the batch. This involves combining the individual calls into a single
+     * call and then calling [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch).
+     *
+     * This should return a Promise that resolves to a `Response` (eg. the return value of `fetch`).
+     *
+     * @param calls All the individual calls that are included in this batch
+     */
+    execute: (calls: EndpointCall<IndividualResult>[], batchKey: BatchKey) => Promise<Response>;
+    /**
+     * After `execute` has finished `resolve` is called for each endpoint call in the batch with the return value of execute
+     * and should return the specific return value for that call. Whereas `execute` combines all the calls into a single
+     * one `resolve` splits the response into the specific parts that each call requires (if applicable).
+     *
+     * @param call A specific call from the batch.
+     * @param nextMiddlewareReturn The result returned from the next middleware in the chain. You can access the result
+     * on the `result` key (this is the value that would be returned normally).
+     * @param batchKey The key for the batch this call was in
+     */
+    resolve: (
+        call: EndpointCall<IndividualResult>,
+        nextMiddlewareReturn: MiddlewareNextReturn<BatchCallReturn>,
+        batchKey: BatchKey
+    ) => IndividualResult;
+    /**
+     * Called after `execute` has resolved if an error is thrown. This can either handle the
+     * error and return the expected result or throw an another error.
+     *
+     * If not provided defaults to re-throwing the error.
+     *
+     * This is called for each endpoint call in the batch.
+     *
+     * @param call A specific call from the batch.
+     * @param error The error created when `execute` either throws an error or when the returned response is not
+     * successful.
+     */
+    reject?: (call: EndpointCall<IndividualResult>, error: ApiError) => IndividualResult;
+    /**
+     * Function used to decode the body of the response.
+     *
+     * If not provided defaults to
+     * - If content-type includes 'json' (eg. application/json) returns decoded json
+     * - If content-type includes 'text (eg. text/plain, text/html) returns text
+     * - If status is 204 or 205 will return null
+     * - Otherwise Response object itself is returned
+     *
+     * This does not use the `Endpoint` `decodeBody` as the `fetch` call itself is specific to the batching
+     * and may not behave the same way. Batching could also happen across multiple different `Endpoint`s in which
+     * case there would be multiple `decodeBody` functions to choose from.
+     *
+     * @param response The response object returned by [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch)
+     */
+    decodeBody?: (response: Response) => any;
+    /**
+     * The time in ms to delay execution of a batch. During this period any calls made to the endpoint will be added to the
+     * same batch. The delay begins as soon as the first item is added to the batch.
+     *
+     * The default is `10`
+     */
+    batchDelay?: number;
+};
+
+type QueueItem<T> = {
+    call: EndpointCall<T>;
+    resolveItem: (value: Response) => void;
+};
+
+class BatchMiddleware<BatchCallReturn, IndividualResult> {
+    queueMap: Map<
+        BatchKey,
+        { isPending: boolean; queue: QueueItem<IndividualResult>[] }
+    > = new Map();
+    getBatchKey: (call: EndpointCall<IndividualResult>) => false | BatchKey;
+    execute: (calls: EndpointCall<IndividualResult>[], batchKey: BatchKey) => Promise<Response>;
+    resolve: (
+        call: EndpointCall<IndividualResult>,
+        nextMiddlewareReturn: MiddlewareNextReturn<BatchCallReturn>,
+        batchKey: BatchKey
+    ) => IndividualResult;
+    reject: (call: EndpointCall<IndividualResult>, error: ApiError) => IndividualResult;
+    decodeBody: (res: Response) => any;
+    batchDelay: number;
+
+    constructor(options: BatchMiddlewareOptions<BatchCallReturn, IndividualResult>) {
+        const {
+            getBatchKey = (): string => 'default',
+            execute,
+            resolve,
+            reject = (call, error): IndividualResult => {
+                throw error;
+            },
+            decodeBody = defaultDecodeBody,
+            batchDelay = 10,
+        } = options;
+        this.batchDelay = batchDelay;
+        this.getBatchKey = getBatchKey;
+        this.execute = execute;
+        this.resolve = resolve;
+        this.reject = reject;
+        this.decodeBody = decodeBody;
+    }
+
+    /**
+     * Clear the queue for a batch key. This executes the call, decodes the body and resolves each
+     * item in the queue to the `Response`. Control is then returned to the internal `Endpoint`
+     * processing which will in turn yield control back to `process` (see after the `await next` call).
+     */
+    async clearQueue(batchKey: BatchKey): Promise<void> {
+        const entry = this.queueMap.get(batchKey);
+        if (!entry) {
+            console.error(`Unexpected: no queue for ${batchKey}`);
+            return;
+        }
+        const { queue } = entry;
+        this.queueMap.set(batchKey, { isPending: false, queue: [] });
+        // All items in queue have to be for same endpoint so we can just look at one of them to get the Endpoint instance
+        const response = await this.execute(
+            queue.map(({ call }) => call),
+            batchKey
+        );
+        queue[0].call.context.markResponseDecoded(response, this.decodeBody(response));
+        queue.map(({ resolveItem }) => {
+            resolveItem(response);
+        });
+    }
+
+    /**
+     * Add an item to the queue for the specified batch. If it's the first item in a batch
+     * it will `setTimeout` and call `clearQueue` after `batchDelay`.
+     */
+    enqueueCall(
+        batchKey: string | number,
+        call: EndpointCall<IndividualResult>
+    ): Promise<Response> {
+        return new Promise(resolveItem => {
+            let entry = this.queueMap.get(batchKey);
+            if (!entry) {
+                entry = {
+                    queue: [],
+                    isPending: false,
+                };
+                this.queueMap.set(batchKey, entry);
+            }
+            entry.queue.push({ call, resolveItem });
+            if (!entry.isPending) {
+                entry.isPending = true;
+                setTimeout(() => {
+                    this.clearQueue(batchKey);
+                }, this.batchDelay);
+            }
+        });
+    }
+
+    init(endpoint: Endpoint): void {
+        const index = endpoint.middleware.indexOf(this);
+        // The only middleware that can proceed batchMiddleware is another batchMiddleware
+        for (let i = index; i < endpoint.middleware.length; i++) {
+            if (!(endpoint.middleware[i] instanceof BatchMiddleware)) {
+                throw new Error('batchMiddleware must appear last in the middleware chain');
+            }
+        }
+    }
+    async process(
+        next,
+        urlConfig: MiddlewareUrlConfig,
+        requestInit: EndpointRequestInit,
+        context: MiddlewareContext<IndividualResult>
+    ): MiddlewareReturn<IndividualResult> {
+        const resolvedUrl = context.endpoint.resolveUrl(
+            urlConfig.pattern,
+            urlConfig.args,
+            urlConfig.query,
+            urlConfig.baseUrl
+        );
+        const call = { urlConfig, requestInit, context, resolvedUrl };
+        const batchKey = this.getBatchKey(call);
+        if (batchKey === false) {
+            return await next(urlConfig, requestInit);
+        }
+
+        try {
+            const result = await next(new SkipToResponse(this.enqueueCall(batchKey, call)));
+            return this.resolve(call, result, batchKey);
+        } catch (error) {
+            return this.reject(call, error);
+        }
+    }
+}
+
+/**
+ * This can be used to batch together multiple calls to an endpoint (or endpoints) to make it more efficient.
+ *
+ * For example say you had an endpoint that accepted either 'id' or a list of 'ids' and would return the corresponding
+ * records with those ids. For ease of use you could allow calling the endpoint with a body of `{ id: 2}` to get the
+ * record of `id=2` but to minimise the number of requests you want to batch together any of these calls into a
+ * combined body of `{ ids: [2, 4, 8] }` but still transparently return each individual record to the original caller
+ * who requested it. This might look like:
+ *
+ * ```js
+ * batchMiddleware({
+ *     execute(calls) {
+ *         // Our implementation of getBatchkey() guarantees that all the endpoints are the same URL (see below)
+ *         const { resolvedUrl } = calls[0];
+ *         // Extract the requested 'id' from each call
+ *         const ids = calls.map(call => {
+ *             return JSON.parse(call.requestInit.body as string).id;
+ *         });
+ *         // Call fetch, merge all other fetch options (headers etc) and create a
+ *         // new body with all the extracted ids.
+ *         return fetch(resolvedUrl, {
+ *              // In this example all calls share the same headers etc
+ *              ...calls[0].requestInit,
+ *             body: JSON.stringify({ ids }),
+ *         });
+ *     },
+ *     // You can also access `response` in addition to `result` if you need the raw `Response` object.
+ *     resolve(call, { result }) {
+ *         // For each call to the endpoint extract only the record it specifically request
+ *         return result[JSON.parse(call.requestInit.body as string).id];
+ *     },
+ * })
+ * ```
+ *
+ * But how do we distinguish between two completely different endpoints? `getBatchKey` can be used to determine how
+ * calls are batched together:
+ *
+ * ```js
+ * batchMiddleware({
+ *   getBatchKey(call) {
+ *     // Batch together all calls that have identical URL (including query parameters)
+ *     return call.resolvedUrl;
+ *   },
+ *   ...
+ * });
+ * ```
+ *
+ * If each call has options to `fetch` that may differ then you can combine them using [mergeRequestInit](doc:mergeRequestInit).
+ * This will combine multiple `init` arguments to [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters)
+ * into a single `init` argument. Headers will be combined into a single headers object with the last argument taking
+ * precedence in the case of a conflict. Any other `init` arguments use the value from the last argument passed.
+ *
+ * ```js
+ * fetch(resolvedUrl, {
+ *   ...mergeRequestInit(...calls.map(call => call.requestInit)),
+ *   body: JSON.stringify({ ids }),
+ * });
+ * ```
+ *
+ * The process for batching looks like:
+ *
+ * * Endpoint is called like normal and hits `batchMiddleware`
+ * * `options.getBatchKey` is called.
+ *   * If this is `false` then the call proceeds to the next middleware in the chain.
+ *   * If this returns anything else that is used as the batch key. If the batch already exist this call is added to the batch
+ *     otherwise a new batch is created and it's execution is scheduled in `batchDelay` milliseconds. The `batchMiddleware`
+ *     will then skip any further middleware in the chain and wait for the `fetch` call.
+ * * Any further calls to an endpoint before the `batchDelay` time elapses are added to the batch
+ * * Once `batchDelay` time elapses `execute` is called which combines all the batched calls into a single call to `fetch`
+ *   * Once `fetch` finishes `decodeBody` is called.
+ *   * `resolve` is then called on success (2xx status) or `reject` called on error (non-2xx status).
+ * * The middleware stack continue to unwind and all middleware before `batchMiddleware` can handle the response/error
+ *    returned by `resolve`/`reject`.
+ *
+ * > **NOTE**: You can have multiple `batchMiddleware` in the middleware chain for an `Endpoint` (including global
+ * > middleware) but only one the first one that chooses to batch an item will ever apply (the others will be skipped).
+ * > This allows you to have multiple conditional batching middleware for example. All `batchMiddleware` must appear
+ * > last in the chain (ie. `batchingMiddleware` can only be proceeded by another `batchMiddleware`).
+ *
+ * @returns Middleware function to pass to [Endpoint](doc:Endpoint) or set on [Endpoint.defaultConfig.middleware](http://localhost:3000/docs/rest/Endpoint#static-var-defaultConfig)
+ * @extract-docs
+ * @menu-group Middleware
+ * @return-type-name Middleware function
+ */
+export default function batchMiddleware<BatchCallReturn, IndividualResult>(
+    options: BatchMiddlewareOptions<BatchCallReturn, IndividualResult>
+): MiddlewareObject<IndividualResult> {
+    return new BatchMiddleware<BatchCallReturn, IndividualResult>(options);
+}
diff --git a/js-packages/@prestojs/rest/src/dedupeInFlightRequestsMiddleware.ts b/js-packages/@prestojs/rest/src/dedupeInFlightRequestsMiddleware.ts
index 158caaa7..3b44affc 100644
--- a/js-packages/@prestojs/rest/src/dedupeInFlightRequestsMiddleware.ts
+++ b/js-packages/@prestojs/rest/src/dedupeInFlightRequestsMiddleware.ts
@@ -2,9 +2,9 @@ import { isDeepEqual } from '@prestojs/util';
 import {
     EndpointRequestInit,
     MiddlewareFunction,
-    MiddlewareNextReturn,
     MiddlewareReturn,
     MiddlewareUrlConfig,
+    SkipToResponse,
 } from './Endpoint';
 
 function defaultGetKey(
@@ -69,17 +69,18 @@ export default function dedupeInFlightRequestsMiddleware<T>(
     options: DedupeOptions = {}
 ): MiddlewareFunction<T> {
     const { test = defaultTest, getKey = defaultGetKey } = options;
-    const requestsInFlight = new Map<any, Promise<MiddlewareNextReturn<T>>>();
+    const requestsInFlight = new Map<any, Promise<Response>>();
     return async (
         next,
         urlConfig: MiddlewareUrlConfig,
-        requestInit: EndpointRequestInit
+        requestInit: EndpointRequestInit,
+        context
     ): MiddlewareReturn<T> => {
         if (!test(urlConfig, requestInit)) {
-            return (await next(urlConfig, requestInit)).result;
+            return next(urlConfig, requestInit);
         }
         const key = getKey(urlConfig, requestInit);
-        let currentRequest: Promise<MiddlewareNextReturn<T>> | null = null;
+        let currentRequest: Promise<Response> | null = null;
         for (const [k, value] of requestsInFlight.entries()) {
             if (isDeepEqual(k, key)) {
                 currentRequest = value;
@@ -87,14 +88,16 @@ export default function dedupeInFlightRequestsMiddleware<T>(
             }
         }
         if (currentRequest) {
-            return (await currentRequest).result;
+            return next(new SkipToResponse(currentRequest));
         }
-        const promise = next(urlConfig, requestInit);
-        requestsInFlight.set(key, promise);
         try {
-            const r = await promise;
-            requestsInFlight.delete(key);
-            return r.result;
+            context.addFetchStartListener(fetchPromise => {
+                requestsInFlight.set(key, fetchPromise);
+                fetchPromise.then(() => {
+                    requestsInFlight.delete(key);
+                });
+            });
+            return await next(urlConfig, requestInit);
         } catch (err) {
             requestsInFlight.delete(key);
             throw err;
diff --git a/js-packages/@prestojs/rest/src/index.ts b/js-packages/@prestojs/rest/src/index.ts
index 56e6b5ec..b2e0a307 100644
--- a/js-packages/@prestojs/rest/src/index.ts
+++ b/js-packages/@prestojs/rest/src/index.ts
@@ -1,9 +1,10 @@
-export { default as Endpoint, ApiError } from './Endpoint';
+export { default as Endpoint, ApiError, mergeRequestInit, SkipToResponse } from './Endpoint';
 export { default as viewModelCachingMiddleware } from './viewModelCachingMiddleware';
 export { default as dedupeInFlightRequestsMiddleware } from './dedupeInFlightRequestsMiddleware';
 export { default as paginationMiddleware } from './paginationMiddleware';
 export { default as requestDefaultsMiddleware } from './requestDefaultsMiddleware';
 export { default as detectBadPaginationMiddleware } from './detectBadPaginationMiddleware';
+export { default as batchMiddleware } from './batchMiddleware';
 
 import type {
     EndpointExecuteOptions,


Test patch:
diff --git a/js-packages/@prestojs/rest/src/__tests__/Endpoint.test.ts b/js-packages/@prestojs/rest/src/__tests__/Endpoint.test.ts
index 1317edbf..48edd2fb 100644
--- a/js-packages/@prestojs/rest/src/__tests__/Endpoint.test.ts
+++ b/js-packages/@prestojs/rest/src/__tests__/Endpoint.test.ts
@@ -26,6 +26,7 @@ beforeEach(() => {
     fetchMock.resetMocks();
     Endpoint.defaultConfig.requestInit = {};
     Endpoint.defaultConfig.middleware = [];
+    Endpoint.defaultConfig.baseUrl = '';
 });
 
 test('prepare should maintain equality based on inputs', () => {
@@ -84,6 +85,30 @@ test('should resolve URLs', () => {
     expect(fetchMock.mock.calls[3][0]).toEqual('/whatever/?a=b');
 });
 
+test('should respect baseUrl', () => {
+    Endpoint.defaultConfig.baseUrl = 'http://example.com/';
+    fetchMock.mockResponse('');
+    const endpoint = new Endpoint(new UrlPattern('/whatever/:id?/'));
+    endpoint.execute();
+    expect(fetchMock.mock.calls.length).toEqual(1);
+    expect(fetchMock.mock.calls[0][0]).toEqual('http://example.com/whatever/');
+    endpoint.execute({ urlArgs: { id: 2 } });
+    expect(fetchMock.mock.calls.length).toEqual(2);
+    expect(fetchMock.mock.calls[1][0]).toEqual('http://example.com/whatever/2/');
+    endpoint.execute({ urlArgs: { id: 2 }, query: { a: 'b' } });
+    expect(fetchMock.mock.calls.length).toEqual(3);
+    expect(fetchMock.mock.calls[2][0]).toEqual('http://example.com/whatever/2/?a=b');
+    endpoint.execute({ query: { a: 'b' } });
+    expect(fetchMock.mock.calls.length).toEqual(4);
+    expect(fetchMock.mock.calls[3][0]).toEqual('http://example.com/whatever/?a=b');
+
+    const endpoint2 = new Endpoint(new UrlPattern('/whatever/:id?/'), {
+        baseUrl: 'http://elsewhere.com',
+    });
+    endpoint2.execute();
+    expect(fetchMock.mock.calls[4][0]).toEqual('http://elsewhere.com/whatever/');
+});
+
 test('should support calling execute without prepare', () => {
     fetchMock.mockResponse('');
     const action = new Endpoint(new UrlPattern('/whatever/:id?/'));
@@ -654,3 +679,36 @@ test('middleware can return either next() directly or result', async () => {
     });
     expect((await endpoint.execute()).result).toBe('HELLO WORLD');
 });
+
+test('middleware can modify baseUrl', async () => {
+    let enabled = false;
+    async function middleware1(next, urlConfig, requestInit): Promise<string> {
+        if (enabled) {
+            urlConfig.baseUrl = 'http://something.com';
+        }
+        const { result } = await next(urlConfig, requestInit);
+        return result.toUpperCase();
+    }
+    const endpoint = new Endpoint(new UrlPattern('/whatever/'), {
+        middleware: [middleware1],
+    });
+    Endpoint.defaultConfig.baseUrl = 'http://example.com/';
+    endpoint.execute();
+    expect(fetchMock.mock.calls[0][0]).toEqual('http://example.com/whatever/');
+
+    enabled = true;
+    endpoint.execute();
+    expect(fetchMock.mock.calls[1][0]).toEqual('http://something.com/whatever/');
+
+    enabled = false;
+    const endpoint2 = new Endpoint(new UrlPattern('/whatever/'), {
+        middleware: [middleware1],
+        baseUrl: 'http://elsewhere.com',
+    });
+    endpoint2.execute();
+    expect(fetchMock.mock.calls[2][0]).toEqual('http://elsewhere.com/whatever/');
+
+    enabled = true;
+    endpoint.execute();
+    expect(fetchMock.mock.calls[3][0]).toEqual('http://something.com/whatever/');
+});
diff --git a/js-packages/@prestojs/rest/src/__tests__/batchMiddleware.test.ts b/js-packages/@prestojs/rest/src/__tests__/batchMiddleware.test.ts
new file mode 100644
index 00000000..34586323
--- /dev/null
+++ b/js-packages/@prestojs/rest/src/__tests__/batchMiddleware.test.ts
@@ -0,0 +1,313 @@
+import { UrlPattern } from '@prestojs/routing';
+import { FetchMock } from 'jest-fetch-mock';
+import batchMiddleware from '../batchMiddleware';
+import Endpoint, { ApiError, mergeRequestInit } from '../Endpoint';
+
+const fetchMock = fetch as FetchMock;
+
+beforeEach(() => {
+    fetchMock.resetMocks();
+    Endpoint.defaultConfig.requestInit = {};
+});
+
+test('should batch requests', async () => {
+    const records = {
+        1: { name: 'One' },
+        2: { name: 'Two' },
+        3: { name: 'Three' },
+    };
+    const endpoint = new Endpoint(new UrlPattern('/whatever/'), {
+        method: 'POST',
+        middleware: [
+            batchMiddleware<Record<string, string>, string>({
+                execute(calls) {
+                    // All the endpoints are the same URL so we can use the first
+                    const { resolvedUrl } = calls[0];
+                    // Extract the requested 'id' from each call
+                    const ids = calls.map(call => {
+                        return JSON.parse(call.requestInit.body as string).id;
+                    });
+                    // Call fetch, merge all other fetch options (headers etc) and create a
+                    // new body with all the extracted ids.
+                    return fetch(resolvedUrl, {
+                        ...mergeRequestInit(...calls.map(call => call.requestInit)),
+                        body: JSON.stringify({ ids }),
+                    });
+                },
+                resolve(call, { result }) {
+                    // For each call to the endpoint extract only the record it specifically request
+                    return result[JSON.parse(call.requestInit.body as string).id];
+                },
+            }),
+        ],
+    });
+    fetchMock.mockResponse(request => {
+        const data = JSON.parse(((request.body as unknown) as Buffer).toString());
+        return Promise.resolve({
+            body: JSON.stringify(
+                data.ids.reduce((acc, id) => {
+                    acc[id] = records[id];
+                    return acc;
+                }, {})
+            ),
+            init: {
+                status: 200,
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+            },
+        });
+    });
+    const first = endpoint.execute({ body: JSON.stringify({ id: 1 }) });
+    const second = endpoint.execute({ body: JSON.stringify({ id: 2 }) });
+    expect((await first).result).toEqual(records[1]);
+    expect((await second).result).toEqual(records[2]);
+    expect(fetchMock).toHaveBeenCalledTimes(1);
+});
+
+test('should respect batch key', async () => {
+    const records = {
+        1: { name: 'One' },
+        2: { name: 'Two' },
+        3: { name: 'Three' },
+    };
+    const endpoint = new Endpoint(new UrlPattern('/whatever/'), {
+        method: 'POST',
+        middleware: [
+            batchMiddleware<Record<string, string>, string>({
+                getBatchKey(call) {
+                    return JSON.parse(call.requestInit.body as string).id < 3 ? 'batch1' : 'batch2';
+                },
+                execute(calls) {
+                    // All the endpoints are the same URL so we can use the first
+                    const { resolvedUrl } = calls[0];
+                    // Extract the requested 'id' from each call
+                    const ids = calls.map(call => {
+                        return JSON.parse(call.requestInit.body as string).id;
+                    });
+                    // Call fetch, merge all other fetch options (headers etc) and create a
+                    // new body with all the extracted ids.
+                    return fetch(resolvedUrl, {
+                        ...mergeRequestInit(...calls.map(call => call.requestInit)),
+                        body: JSON.stringify({ ids }),
+                    });
+                },
+                resolve(call, { result }) {
+                    // For each call to the endpoint extract only the record it specifically request
+                    return result[JSON.parse(call.requestInit.body as string).id];
+                },
+            }),
+        ],
+    });
+    fetchMock.mockResponse(request => {
+        const data = JSON.parse(((request.body as unknown) as Buffer).toString());
+        return Promise.resolve({
+            body: JSON.stringify(
+                data.ids.reduce((acc, id) => {
+                    acc[id] = records[id];
+                    return acc;
+                }, {})
+            ),
+            init: {
+                status: 200,
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+            },
+        });
+    });
+    const first = endpoint.execute({ body: JSON.stringify({ id: 1 }) });
+    const second = endpoint.execute({ body: JSON.stringify({ id: 2 }) });
+    const third = endpoint.execute({ body: JSON.stringify({ id: 3 }) });
+    expect((await first).result).toEqual(records[1]);
+    expect((await second).result).toEqual(records[2]);
+    expect((await third).result).toEqual(records[3]);
+    expect(fetchMock).toHaveBeenNthCalledWith(
+        1,
+        '/whatever/',
+        expect.objectContaining({ body: JSON.stringify({ ids: [1, 2] }) })
+    );
+    expect(fetchMock).toHaveBeenNthCalledWith(
+        2,
+        '/whatever/',
+        expect.objectContaining({ body: JSON.stringify({ ids: [3] }) })
+    );
+    expect(fetchMock).toHaveBeenCalledTimes(2);
+});
+
+test('should support reject', async () => {
+    const endpoint = new Endpoint(new UrlPattern('/whatever/'), {
+        method: 'POST',
+        middleware: [
+            batchMiddleware<Record<string, string>, string>({
+                execute(calls) {
+                    // All the endpoints are the same URL so we can use the first
+                    const { resolvedUrl } = calls[0];
+                    // Extract the requested 'id' from each call
+                    const ids = calls.map(call => {
+                        return JSON.parse(call.requestInit.body as string).id;
+                    });
+                    // Call fetch, merge all other fetch options (headers etc) and create a
+                    // new body with all the extracted ids.
+                    return fetch(resolvedUrl, {
+                        ...mergeRequestInit(...calls.map(call => call.requestInit)),
+                        body: JSON.stringify({ ids }),
+                    });
+                },
+                reject(call, error) {
+                    const { id } = JSON.parse(call.requestInit.body as string);
+                    throw new ApiError(400, `Cannot get ${id}`, error.content);
+                },
+                resolve(call, { result }) {
+                    return result[JSON.parse(call.requestInit.body as string).id];
+                },
+            }),
+        ],
+    });
+    fetchMock.mockResponse(request => {
+        return Promise.resolve({
+            body: JSON.stringify({ error: 'Bad request' }),
+            init: {
+                status: 400,
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+            },
+        });
+    });
+    const first = endpoint.execute({ body: JSON.stringify({ id: 1 }) });
+    const second = endpoint.execute({ body: JSON.stringify({ id: 2 }) });
+    await expect(first).rejects.toThrowError(
+        new ApiError(400, 'Cannot get 1', { error: 'Bad request' })
+    );
+    await expect(second).rejects.toThrowError(
+        new ApiError(400, 'Cannot get 2', { error: 'Bad request' })
+    );
+});
+
+test('should support decodeBody', async () => {
+    const records = {
+        1: { name: 'One' },
+        2: { name: 'Two' },
+        3: { name: 'Three' },
+    };
+    const endpoint = new Endpoint<{ name: string }>(new UrlPattern('/whatever/'), {
+        method: 'POST',
+        middleware: [
+            batchMiddleware<Record<string, { name: string }>, { name: string }>({
+                execute(calls) {
+                    // All the endpoints are the same URL so we can use the first
+                    const { resolvedUrl } = calls[0];
+                    // Extract the requested 'id' from each call
+                    const ids = calls.map(call => {
+                        return JSON.parse(call.requestInit.body as string).id;
+                    });
+                    // Call fetch, merge all other fetch options (headers etc) and create a
+                    // new body with all the extracted ids.
+                    return fetch(resolvedUrl, {
+                        ...mergeRequestInit(...calls.map(call => call.requestInit)),
+                        body: JSON.stringify({ ids }),
+                    });
+                },
+                resolve(call, { result }) {
+                    // For each call to the endpoint extract only the record it specifically request
+                    return result[JSON.parse(call.requestInit.body as string).id];
+                },
+                async decodeBody(response) {
+                    return Object.entries(
+                        (await response.json()) as Record<string, { name: string }>
+                    ).reduce((acc, [id, entry]) => {
+                        acc[id] = { name: entry.name.toUpperCase() };
+                        return acc;
+                    }, {});
+                },
+            }),
+        ],
+    });
+    fetchMock.mockResponse(request => {
+        const data = JSON.parse(((request.body as unknown) as Buffer).toString());
+        return Promise.resolve({
+            body: JSON.stringify(
+                data.ids.reduce((acc, id) => {
+                    acc[id] = records[id];
+                    return acc;
+                }, {})
+            ),
+            init: {
+                status: 200,
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+            },
+        });
+    });
+    const first = endpoint.execute({ body: JSON.stringify({ id: 1 }) });
+    const second = endpoint.execute({ body: JSON.stringify({ id: 2 }) });
+    expect((await first).result.name).toEqual(records[1].name.toUpperCase());
+    expect((await second).result.name).toEqual(records[2].name.toUpperCase());
+    expect(fetchMock).toHaveBeenCalledTimes(1);
+});
+
+test('should allow multiple batchMiddleware', async () => {
+    let middleware1Count = 1;
+    let middleware2Count = 1;
+    const middleware = [
+        batchMiddleware<Record<string, string>, string>({
+            getBatchKey(call) {
+                return call.resolvedUrl === '/url1/' ? '/url1/' : false;
+            },
+            execute(calls, batchKey) {
+                return fetch(batchKey);
+            },
+            resolve(call, { result }, batchKey) {
+                return `/url1/ ${middleware1Count++}`;
+            },
+        }),
+        batchMiddleware<Record<string, string>, string>({
+            getBatchKey(call) {
+                return call.resolvedUrl === '/url2/' ? '/url2/' : false;
+            },
+            execute(calls, batchKey) {
+                return fetch(batchKey);
+            },
+            resolve(call, { result }) {
+                return `/url2/ ${middleware2Count++}`;
+            },
+        }),
+    ];
+    const endpoint1 = new Endpoint(new UrlPattern('/url1/'), {
+        method: 'POST',
+        middleware,
+    });
+    const endpoint2 = new Endpoint(new UrlPattern('/url2/'), {
+        method: 'POST',
+        middleware,
+    });
+    fetchMock.mockResponse('');
+    const first = [endpoint1.execute(), endpoint1.execute()];
+    const second = [endpoint2.execute(), endpoint2.execute()];
+    expect((await Promise.all(first)).map(r => r.result)).toEqual(['/url1/ 1', '/url1/ 2']);
+    expect((await Promise.all(second)).map(r => r.result)).toEqual(['/url2/ 1', '/url2/ 2']);
+});
+
+test('should enforce batchMiddleware in final position(s)', () => {
+    expect(() => {
+        new Endpoint(new UrlPattern('/whatever/'), {
+            method: 'POST',
+            middleware: [
+                batchMiddleware<Record<string, string>, string>({
+                    execute(calls) {
+                        return fetch('');
+                    },
+                    resolve(call, { result }) {
+                        return '';
+                    },
+                }),
+                // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
+                (next, urlConfig, requestInit) => {
+                    return next(urlConfig, requestInit);
+                },
+            ],
+        });
+    }).toThrowError('batchMiddleware must appear last in the middleware chain');
+});
diff --git a/js-packages/@prestojs/rest/src/__tests__/dedupeInFlightRequestsMiddleware.test.ts b/js-packages/@prestojs/rest/src/__tests__/dedupeInFlightRequestsMiddleware.test.ts
index 8c4ff6bf..c51a3edd 100644
--- a/js-packages/@prestojs/rest/src/__tests__/dedupeInFlightRequestsMiddleware.test.ts
+++ b/js-packages/@prestojs/rest/src/__tests__/dedupeInFlightRequestsMiddleware.test.ts
@@ -29,6 +29,8 @@ test('should de-dupe in flight requests', async () => {
     const p2 = action1.prepare().execute();
     expect((await p1).result).toBe('hello world 0');
     expect((await p2).result).toBe('hello world 0');
+    // All aspects of the response should be equivalent
+    expect(await p1).toEqual(await p2);
     expect(fetchMock).toHaveBeenCalledTimes(1);
     expect((await action1.prepare().execute()).result).toBe('hello world 1');
     expect(fetchMock).toHaveBeenCalledTimes(2);
@@ -37,6 +39,7 @@ test('should de-dupe in flight requests', async () => {
     const p4 = action1.prepare().execute(init1);
     expect((await p3).result).toBe('hello world 2');
     expect((await p4).result).toBe('hello world 2');
+    expect(await p3).toEqual(await p4);
     expect(fetchMock).toHaveBeenCalledTimes(3);
     expect((await action1.prepare().execute(init1)).result).toBe('hello world 3');
     expect(fetchMock).toHaveBeenCalledTimes(4);
@@ -56,6 +59,7 @@ test('should de-dupe in flight requests', async () => {
         .execute({ headers: new Headers({ 'x-test2': '2', 'x-test1': '1' }) });
     expect((await p7).result).toBe('hello world 6');
     expect((await p8).result).toBe('hello world 6');
+    expect(await p7).toEqual(await p8);
     expect(fetchMock).toHaveBeenCalledTimes(7);
 });