Repository: wheresrhys/fetch-mock
Pull request #728 (2024-07-21 10:40:06)

Matchers expect normalizedRequest
preparation for #656 


Patch:
diff --git a/docs/docs/@fetch-mock/core/more-routing-methods.md b/docs/docs/@fetch-mock/core/more-routing-methods.md
index c3795db..9df0fa9 100644
--- a/docs/docs/@fetch-mock/core/more-routing-methods.md
+++ b/docs/docs/@fetch-mock/core/more-routing-methods.md
@@ -95,7 +95,9 @@ If your matcher requires access to the body of the request set this to true; bec
 
 `{Function}`
 
-A function which takes a route definition object as input, and returns a function of the signature `(url, options, request) => Boolean`. See the examples below for more detail. The function is passed the fetchMock instance as a second parameter in case you need to access any config.
+A function which takes a route definition object as input, and returns a function of the signature `(NormalizedRequest) => Boolean`. See the examples below for more detail. The function is passed the fetchMock instance as a second parameter in case you need to access any config.
+
+// TODO at time of writing the NormalizedRequest schema is still evolving, so more detailed docs will emerge soon
 
 ##### Examples
 
diff --git a/docs/docs/@fetch-mock/core/route/response.md b/docs/docs/@fetch-mock/core/route/response.md
index 444da0e..81bd4c1 100644
--- a/docs/docs/@fetch-mock/core/route/response.md
+++ b/docs/docs/@fetch-mock/core/route/response.md
@@ -76,9 +76,9 @@ A `Promise` that resolves to any of the options documented above e.g. `new Promi
 
 `{Function}`
 
-A function that returns any of the options documented above (including `Promise`. The function will be passed the `url` and `options` `fetch` was called with. If `fetch` was called with a `Request` instance, it will be passed `url` and `options` inferred from the `Request` instance, with the original `Request` passed as a third argument.
+A function that is passed a [`CallLog`](/fetch-mock/docs/@fetch-mock/core/CallHistory#calllog-schema) and returns any of the options documented above (including `Promise`).
 
 ### Examples
 
-- `(url, opts) => opts.headers.Authorization ? 200 : 403`
-- `(_, _, request) => request.headers.get('Authorization') ?  200 : 403`
+- `({url, options}) => options.headers.Authorization ? 200 : 403`
+- `({request}) => request.headers.get('Authorization') ?  200 : 403`
diff --git a/packages/core/src/CallHistory.js b/packages/core/src/CallHistory.js
index 3f57aa0..3886175 100644
--- a/packages/core/src/CallHistory.js
+++ b/packages/core/src/CallHistory.js
@@ -137,12 +137,7 @@ class CallHistory {
 		});
 
 		calls = calls.filter(({ url, options }) => {
-			const {
-				url: normalizedUrl,
-				options: normalizedOptions,
-				request,
-			} = normalizeRequest(url, options, this.config.Request);
-			return matcher(normalizedUrl, normalizedOptions, request);
+			return matcher(normalizeRequest(url, options, this.config.Request));
 		});
 
 		return calls;
diff --git a/packages/core/src/FetchMock.js b/packages/core/src/FetchMock.js
index e893cc1..3357d3d 100644
--- a/packages/core/src/FetchMock.js
+++ b/packages/core/src/FetchMock.js
@@ -80,6 +80,7 @@ const FetchMock = {
 			requestInit,
 			this.config.Request,
 		);
+
 		/** @type {Promise<any>[]} */
 		const pendingPromises = [];
 		const callLog = { ...normalizedRequest, pendingPromises };
diff --git a/packages/core/src/Matchers.js b/packages/core/src/Matchers.js
index 50f540c..3cd4482 100644
--- a/packages/core/src/Matchers.js
+++ b/packages/core/src/Matchers.js
@@ -1,6 +1,6 @@
 //@type-check
 /** @typedef {import('./Route').RouteConfig} RouteConfig */
-/** @typedef {import('./RequestUtils').NormalizedRequestOptions} NormalizedRequestOptions */
+/** @typedef {import('./RequestUtils').NormalizedRequest} NormalizedRequest */
 import glob from 'globrex';
 import * as regexparam from 'regexparam';
 import querystring from 'querystring';
@@ -32,7 +32,7 @@ export const isFunctionMatcher = (matcher) => typeof matcher === 'function';
 /** @typedef {string | RegExp | URL} RouteMatcherUrl */
 /** @typedef {function(string): boolean} UrlMatcher */
 /** @typedef {function(string): UrlMatcher} UrlMatcherGenerator */
-/** @typedef {function(string, NormalizedRequestOptions, Request): boolean} RouteMatcherFunction */
+/** @typedef {function(NormalizedRequest): boolean} RouteMatcherFunction */
 /** @typedef {function(RouteConfig): RouteMatcherFunction} MatcherGenerator */
 /** @typedef {RouteMatcherUrl | RouteMatcherFunction} RouteMatcher */
 
@@ -47,19 +47,27 @@ export const isFunctionMatcher = (matcher) => typeof matcher === 'function';
  * @type {Object.<string, UrlMatcherGenerator>}
  */
 const stringMatchers = {
-	begin: (targetString) => (url) => url.indexOf(targetString) === 0,
-	end: (targetString) => (url) =>
-		url.substr(-targetString.length) === targetString,
+	begin:
+		(targetString) =>
+		({ url }) =>
+			url.indexOf(targetString) === 0,
+	end:
+		(targetString) =>
+		({ url }) =>
+			url.substr(-targetString.length) === targetString,
 
 	glob: (targetString) => {
 		const urlRX = glob(targetString);
-		return (url) => urlRX.regex.test(url);
+		return ({ url }) => urlRX.regex.test(url);
 	},
 	express: (targetString) => {
 		const urlRX = regexparam.parse(targetString);
-		return (url) => urlRX.pattern.test(getPath(url));
+		return ({ url }) => urlRX.pattern.test(getPath(url));
 	},
-	path: (targetString) => (url) => getPath(url) === targetString,
+	path:
+		(targetString) =>
+		({ url }) =>
+			getPath(url) === targetString,
 };
 /**
  * @type {MatcherGenerator}
@@ -69,7 +77,7 @@ const getHeaderMatcher = ({ headers: expectedHeaders }) => {
 		return;
 	}
 	const expectation = normalizeHeaders(expectedHeaders);
-	return (url, { headers = {} }) => {
+	return ({ options: { headers = {} } }) => {
 		// TODO do something to handle multi value headers
 		const lowerCaseHeaders = normalizeHeaders(headers);
 		return Object.keys(expectation).every(
@@ -84,7 +92,7 @@ const getMethodMatcher = ({ method: expectedMethod }) => {
 	if (!expectedMethod) {
 		return;
 	}
-	return (url, { method }) => {
+	return ({ options: { method } = {} }) => {
 		const actualMethod = method ? method.toLowerCase() : 'get';
 		return expectedMethod === actualMethod;
 	};
@@ -98,7 +106,7 @@ const getQueryStringMatcher = ({ query: passedQuery }) => {
 	}
 	const expectedQuery = querystring.parse(querystring.stringify(passedQuery));
 	const keys = Object.keys(expectedQuery);
-	return (url) => {
+	return ({ url }) => {
 		const query = querystring.parse(getQuery(url));
 		return keys.every((key) => {
 			if (Array.isArray(query[key])) {
@@ -129,7 +137,7 @@ const getParamsMatcher = ({ params: expectedParams, url: matcherUrl }) => {
 		}
 		const expectedKeys = Object.keys(expectedParams);
 		const re = regexparam.parse(matcherUrl.replace(/^express:/, ''));
-		return (url) => {
+		return ({ url }) => {
 			const vals = re.pattern.exec(getPath(url)) || [];
 			vals.shift();
 			/** @type {Object.<string,string>} */
@@ -148,7 +156,8 @@ const getParamsMatcher = ({ params: expectedParams, url: matcherUrl }) => {
 const getBodyMatcher = (route) => {
 	const { body: expectedBody } = route;
 
-	return (url, { body, method = 'get' }) => {
+	return ({ url, options: { body, method = 'get' } }) => {
+		console.log({ url, body, method });
 		if (method.toLowerCase() === 'get') {
 			// GET requests don’t send a body so the body matcher should be ignored for them
 			return true;
@@ -185,7 +194,7 @@ const getFullUrlMatcher = (route, matcherUrl, query) => {
 		route.url = expectedUrl;
 	}
 
-	return (url) => {
+	return ({ url }) => {
 		if (query && expectedUrl.indexOf('?')) {
 			return getPath(url) === getPath(expectedUrl);
 		}
@@ -208,7 +217,7 @@ const getUrlMatcher = (route) => {
 	}
 
 	if (matcherUrl instanceof RegExp) {
-		return (url) => matcherUrl.test(url);
+		return ({ url }) => matcherUrl.test(url);
 	}
 	if (matcherUrl instanceof URL) {
 		if (matcherUrl.href) {
diff --git a/packages/core/src/RequestUtils.js b/packages/core/src/RequestUtils.js
index 65dad0b..838fc8b 100644
--- a/packages/core/src/RequestUtils.js
+++ b/packages/core/src/RequestUtils.js
@@ -53,7 +53,7 @@ const isRequest = (urlOrRequest, Request) =>
  * @param {typeof Request} Request
  * @returns {NormalizedRequest}
  */
-export function normalizeRequest(urlOrRequest, options, Request) {
+export function normalizeRequest(urlOrRequest, options = {}, Request) {
 	if (isRequest(urlOrRequest, Request)) {
 		/** @type {NormalizedRequestOptions} */
 		const derivedOptions = {
diff --git a/packages/core/src/Route.js b/packages/core/src/Route.js
index 1341b44..c3aa15e 100644
--- a/packages/core/src/Route.js
+++ b/packages/core/src/Route.js
@@ -148,8 +148,8 @@ class Route {
 			}));
 		this.config.usesBody = activeMatchers.some(({ usesBody }) => usesBody);
 		/** @type {RouteMatcherFunction} */
-		this.matcher = (url, options = {}, request) =>
-			activeMatchers.every(({ matcher }) => matcher(url, options, request));
+		this.matcher = (normalizedRequest) =>
+			activeMatchers.every(({ matcher }) => matcher(normalizedRequest));
 	}
 	/**
 	 * @returns {void}
diff --git a/packages/core/src/Router.js b/packages/core/src/Router.js
index 7e3c0fb..f1ad4d7 100644
--- a/packages/core/src/Router.js
+++ b/packages/core/src/Router.js
@@ -100,7 +100,6 @@ function shouldSendAsObject(responseInput) {
  * @returns
  */
 const resolveUntilResponseConfig = async (response, normalizedRequest) => {
-	const { url, options, request } = normalizedRequest;
 	// We want to allow things like
 	// - function returning a Promise for a response
 	// - delaying (using a timeout Promise) a function's execution to generate
@@ -111,7 +110,7 @@ const resolveUntilResponseConfig = async (response, normalizedRequest) => {
 	//eslint-disable-next-line no-constant-condition
 	while (true) {
 		if (typeof response === 'function') {
-			response = response(url, options, request);
+			response = response(normalizedRequest);
 		} else if (isPromise(response)) {
 			response = await response; // eslint-disable-line  no-await-in-loop
 		} else {
@@ -164,7 +163,7 @@ export default class Router {
 				}
 				normalizedRequest.signal.addEventListener('abort', abort);
 			}
-
+			console.log(this.routes);
 			if (this.needsToReadBody(request)) {
 				options.body = await options.body;
 			}
@@ -173,7 +172,7 @@ export default class Router {
 				? [...this.routes, this.fallbackRoute]
 				: this.routes;
 			const route = routesToTry.find((route) =>
-				route.matcher(url, options, request),
+				route.matcher(normalizedRequest),
 			);
 
 			if (route) {


Test patch:
diff --git a/packages/core/src/__tests__/CallHistory.test.js b/packages/core/src/__tests__/CallHistory.test.js
index 47c7098..3b31bcd 100644
--- a/packages/core/src/__tests__/CallHistory.test.js
+++ b/packages/core/src/__tests__/CallHistory.test.js
@@ -231,7 +231,7 @@ describe('CallHistory', () => {
 				});
 
 				describe('filtering with a matcher', () => {
-					//TODO write a test that just makes it clear this is contracted out to Route
+					// TODO write a test that just makes it clear this is contracted out to Route
 					// spy on route constructor, and then on matcher for that route
 					it('should be able to filter with a url matcher', async () => {
 						fm.catch();
diff --git a/packages/core/src/__tests__/FetchMock/response-construction.test.js b/packages/core/src/__tests__/FetchMock/response-construction.test.js
index c4bb565..95d5deb 100644
--- a/packages/core/src/__tests__/FetchMock/response-construction.test.js
+++ b/packages/core/src/__tests__/FetchMock/response-construction.test.js
@@ -197,7 +197,7 @@ describe('response generation', () => {
 	});
 
 	it('construct a response based on the request', async () => {
-		fm.route('*', (url, opts) => url + opts.headers.header);
+		fm.route('*', ({ url, options }) => url + options.headers.header);
 		const res = await fm.fetchHandler('http://a.com/', {
 			headers: { header: 'val' },
 		});
@@ -206,7 +206,7 @@ describe('response generation', () => {
 	});
 
 	it('construct a response based on a Request instance', async () => {
-		fm.route('*', (url, opts, request) => request.json().then(({ a }) => a));
+		fm.route('*', ({ request }) => request.json().then(({ a }) => a));
 		const res = await fm.fetchHandler(
 			new fm.config.Request('http://a.com', {
 				body: JSON.stringify({ a: 'b' }),
diff --git a/packages/core/src/__tests__/FetchMock/response-negotiation.test.js b/packages/core/src/__tests__/FetchMock/response-negotiation.test.js
index 78cc3db..a930a3e 100644
--- a/packages/core/src/__tests__/FetchMock/response-negotiation.test.js
+++ b/packages/core/src/__tests__/FetchMock/response-negotiation.test.js
@@ -9,7 +9,7 @@ describe('response negotiation', () => {
 	});
 
 	it('function', async () => {
-		fm.route('*', (url) => url);
+		fm.route('*', ({ url }) => url);
 		const res = await fm.fetchHandler('http://a.com/');
 		expect(res.status).toEqual(200);
 		expect(await res.text()).toEqual('http://a.com/');
@@ -27,7 +27,7 @@ describe('response negotiation', () => {
 		expect(res.status).toEqual(300);
 	});
 	it('function that returns a Promise for a body', async () => {
-		fm.route('*', (url) => Promise.resolve(`test: ${url}`));
+		fm.route('*', ({ url }) => Promise.resolve(`test: ${url}`));
 		const res = await fm.fetchHandler('http://a.com/');
 		expect(res.status).toEqual(200);
 		expect(await res.text()).toEqual('test: http://a.com/');
@@ -36,7 +36,7 @@ describe('response negotiation', () => {
 	it('Promise for a function that returns a response', async () => {
 		fm.route(
 			'http://a.com/',
-			Promise.resolve((url) => `test: ${url}`),
+			Promise.resolve(({ url }) => `test: ${url}`),
 		);
 		const res = await fm.fetchHandler('http://a.com/');
 		expect(res.status).toEqual(200);
@@ -79,7 +79,7 @@ describe('response negotiation', () => {
 	});
 
 	it('pass values to delayed function', async () => {
-		fm.route('*', (url) => `delayed: ${url}`, {
+		fm.route('*', ({ url }) => `delayed: ${url}`, {
 			delay: 10,
 		});
 		const req = fm.fetchHandler('http://a.com/');
diff --git a/packages/core/src/__tests__/Matchers/body.test.js b/packages/core/src/__tests__/Matchers/body.test.js
index 911434b..676b4a8 100644
--- a/packages/core/src/__tests__/Matchers/body.test.js
+++ b/packages/core/src/__tests__/Matchers/body.test.js
@@ -1,14 +1,18 @@
 import { describe, expect, it } from 'vitest';
 import Route from '../../Route.js';
-
+import Router from '../../Router.js';
+import { normalizeRequest } from '../../RequestUtils.js';
 describe('body matching', () => {
 	//TODO add a test for matching an asynchronous body
 	it('should not match if no body provided in request', () => {
 		const route = new Route({ body: { foo: 'bar' }, response: 200 });
 
 		expect(
-			route.matcher('http://a.com/', {
-				method: 'POST',
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					method: 'POST',
+				},
 			}),
 		).toBe(false);
 	});
@@ -17,34 +21,53 @@ describe('body matching', () => {
 		const route = new Route({ body: { foo: 'bar' }, response: 200 });
 
 		expect(
-			route.matcher('http://a.com/', {
-				method: 'POST',
-				body: JSON.stringify({ foo: 'bar' }),
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					method: 'POST',
+					body: JSON.stringify({ foo: 'bar' }),
+				},
 			}),
 		).toBe(true);
 	});
 
-	it('should match when using Request', () => {
-		const route = new Route({ body: { foo: 'bar' }, response: 200 });
-
-		expect(
-			route.matcher(
-				new Request('http://a.com/', {
-					method: 'POST',
-					body: JSON.stringify({ foo: 'bar' }),
-				}),
-			),
-		).toBe(true);
+	// Note, using Router to test this as normalization of Request to normalizedRequest
+	// happens in there
+	// TODO Q: Should it?
+	// TODO need to split execute into 2??
+	// 1. .route() (which can be used to test Routes with normalization applied up front)
+	// 2. .respond()
+	it('should match when using Request', async () => {
+		const route = new Route({
+			body: { foo: 'bar' },
+			response: 200,
+			Headers,
+			Response,
+		});
+		const router = new Router({ Request, Headers }, { routes: [route] });
+		const normalizedRequest = normalizeRequest(
+			new Request('http://a.com/', {
+				method: 'POST',
+				body: JSON.stringify({ foo: 'bar' }),
+			}),
+			undefined,
+			Request,
+		);
+		const response = await router.execute(normalizedRequest, normalizedRequest);
+		expect(response.status).toBe(200);
 	});
 
 	it('should match if body sent matches expected body', () => {
 		const route = new Route({ body: { foo: 'bar' }, response: 200 });
 
 		expect(
-			route.matcher('http://a.com/', {
-				method: 'POST',
-				body: JSON.stringify({ foo: 'bar' }),
-				headers: { 'Content-Type': 'application/json' },
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					method: 'POST',
+					body: JSON.stringify({ foo: 'bar' }),
+					headers: { 'Content-Type': 'application/json' },
+				},
 			}),
 		).toBe(true);
 	});
@@ -53,10 +76,13 @@ describe('body matching', () => {
 		const route = new Route({ body: { foo: 'bar' }, response: 200 });
 
 		expect(
-			route.matcher('http://a.com/', {
-				method: 'POST',
-				body: JSON.stringify({ foo: 'woah!!!' }),
-				headers: { 'Content-Type': 'application/json' },
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					method: 'POST',
+					body: JSON.stringify({ foo: 'woah!!!' }),
+					headers: { 'Content-Type': 'application/json' },
+				},
 			}),
 		).toBe(false);
 	});
@@ -65,10 +91,13 @@ describe('body matching', () => {
 		const route = new Route({ body: { foo: 'bar' }, response: 200 });
 
 		expect(
-			route.matcher('http://a.com/', {
-				method: 'POST',
-				body: new ArrayBuffer(8),
-				headers: { 'Content-Type': 'application/json' },
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					method: 'POST',
+					body: new ArrayBuffer(8),
+					headers: { 'Content-Type': 'application/json' },
+				},
 			}),
 		).toBe(false);
 	});
@@ -84,19 +113,22 @@ describe('body matching', () => {
 		});
 
 		expect(
-			route.matcher('http://a.com/', {
-				method: 'POST',
-				body: JSON.stringify({
-					baz: 'qux',
-					foo: 'bar',
-				}),
-				headers: { 'Content-Type': 'application/json' },
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					method: 'POST',
+					body: JSON.stringify({
+						baz: 'qux',
+						foo: 'bar',
+					}),
+					headers: { 'Content-Type': 'application/json' },
+				},
 			}),
 		).toBe(true);
 	});
 
 	// TODO - I think this shoudl actually throw
-	it('should ignore the body option matcher if request was GET', () => {
+	it.skip('should ignore the body option matcher if request was GET', () => {
 		const route = new Route({
 			body: {
 				foo: 'bar',
@@ -106,7 +138,7 @@ describe('body matching', () => {
 			response: 200,
 		});
 
-		expect(route.matcher('http://a.com/')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com/' })).toBe(true);
 	});
 
 	describe('partial body matching', () => {
@@ -117,9 +149,12 @@ describe('body matching', () => {
 				response: 200,
 			});
 			expect(
-				route.matcher('http://a.com', {
-					method: 'POST',
-					body: JSON.stringify({ ham: 'sandwich', egg: 'mayonaise' }),
+				route.matcher({
+					url: 'http://a.com',
+					options: {
+						method: 'POST',
+						body: JSON.stringify({ ham: 'sandwich', egg: 'mayonaise' }),
+					},
 				}),
 			).toBe(true);
 		});
@@ -131,11 +166,14 @@ describe('body matching', () => {
 				response: 200,
 			});
 			expect(
-				route.matcher('http://a.com', {
-					method: 'POST',
-					body: JSON.stringify({
-						meal: { ham: 'sandwich', egg: 'mayonaise' },
-					}),
+				route.matcher({
+					url: 'http://a.com',
+					options: {
+						method: 'POST',
+						body: JSON.stringify({
+							meal: { ham: 'sandwich', egg: 'mayonaise' },
+						}),
+					},
 				}),
 			).toBe(true);
 		});
@@ -147,9 +185,12 @@ describe('body matching', () => {
 				response: 200,
 			});
 			expect(
-				route.matcher('http://a.com', {
-					method: 'POST',
-					body: JSON.stringify({ meal: { ham: 'sandwich' } }),
+				route.matcher({
+					url: 'http://a.com',
+					options: {
+						method: 'POST',
+						body: JSON.stringify({ meal: { ham: 'sandwich' } }),
+					},
 				}),
 			).toBe(false);
 		});
@@ -161,9 +202,12 @@ describe('body matching', () => {
 				response: 200,
 			});
 			expect(
-				route.matcher('http://a.com', {
-					method: 'POST',
-					body: JSON.stringify({ ham: [1, 2, 3] }),
+				route.matcher({
+					url: 'http://a.com',
+					options: {
+						method: 'POST',
+						body: JSON.stringify({ ham: [1, 2, 3] }),
+					},
 				}),
 			).toBe(true);
 		});
@@ -175,9 +219,12 @@ describe('body matching', () => {
 				response: 200,
 			});
 			expect(
-				route.matcher('http://a.com', {
-					method: 'POST',
-					body: JSON.stringify({ ham: [1, 2, 3] }),
+				route.matcher({
+					url: 'http://a.com',
+					options: {
+						method: 'POST',
+						body: JSON.stringify({ ham: [1, 2, 3] }),
+					},
 				}),
 			).toBe(false);
 		});
diff --git a/packages/core/src/__tests__/Matchers/express.test.js b/packages/core/src/__tests__/Matchers/express.test.js
index 0f19ef1..678799e 100644
--- a/packages/core/src/__tests__/Matchers/express.test.js
+++ b/packages/core/src/__tests__/Matchers/express.test.js
@@ -8,9 +8,9 @@ describe('express path parameter matching', () => {
 			response: 200,
 			params: { instance: 'b' },
 		});
-		expect(route.matcher('/')).toBe(false);
-		expect(route.matcher('/type/a')).toBe(false);
-		expect(route.matcher('/type/b')).toBe(true);
+		expect(route.matcher({ url: '/' })).toBe(false);
+		expect(route.matcher({ url: '/type/a' })).toBe(false);
+		expect(route.matcher({ url: '/type/b' })).toBe(true);
 	});
 
 	it('can match multiple path parameters', () => {
@@ -19,11 +19,11 @@ describe('express path parameter matching', () => {
 			response: 200,
 			params: { instance: 'b', type: 'cat' },
 		});
-		expect(route.matcher('/')).toBe(false);
-		expect(route.matcher('/dog/a')).toBe(false);
-		expect(route.matcher('/cat/a')).toBe(false);
-		expect(route.matcher('/dog/b')).toBe(false);
-		expect(route.matcher('/cat/b')).toBe(true);
+		expect(route.matcher({ url: '/' })).toBe(false);
+		expect(route.matcher({ url: '/dog/a' })).toBe(false);
+		expect(route.matcher({ url: '/cat/a' })).toBe(false);
+		expect(route.matcher({ url: '/dog/b' })).toBe(false);
+		expect(route.matcher({ url: '/cat/b' })).toBe(true);
 	});
 
 	it('can match a path parameter on a full url', () => {
@@ -32,14 +32,16 @@ describe('express path parameter matching', () => {
 			response: 200,
 			params: { instance: 'b' },
 		});
-		expect(route.matcher('http://site.com/')).toBe(false);
-		expect(route.matcher('http://site.com/type/a')).toBe(false);
-		expect(route.matcher('http://site.com/type/b')).toBe(true);
+		expect(route.matcher({ url: 'http://site.com/' })).toBe(false);
+		expect(route.matcher({ url: 'http://site.com/type/a' })).toBe(false);
+		expect(route.matcher({ url: 'http://site.com/type/b' })).toBe(true);
 	});
 
 	it('can match fully qualified url', () => {
 		const route = new Route({ url: 'express:/apps/:id', response: 200 });
 
-		expect(route.matcher('https://api.example.com/apps/abc')).toBe(true);
+		expect(route.matcher({ url: 'https://api.example.com/apps/abc' })).toBe(
+			true,
+		);
 	});
 });
diff --git a/packages/core/src/__tests__/Matchers/function.test.js b/packages/core/src/__tests__/Matchers/function.test.js
index d0f4d0d..7efc066 100644
--- a/packages/core/src/__tests__/Matchers/function.test.js
+++ b/packages/core/src/__tests__/Matchers/function.test.js
@@ -4,37 +4,50 @@ import Route from '../../Route.js';
 describe('function matching', () => {
 	it('match using custom function', () => {
 		const route = new Route({
-			matcherFunction: (url, opts) =>
+			matcherFunction: ({ url, options }) =>
 				url.indexOf('logged-in') > -1 &&
-				opts &&
-				opts.headers &&
-				opts.headers.authorized === true,
+				options &&
+				options.headers &&
+				options.headers.authorized === true,
 			response: 200,
 		});
 
 		expect(
-			route.matcher('http://a.com/12345', {
-				headers: { authorized: true },
+			route.matcher({
+				url: 'http://a.com/12345',
+				options: {
+					headers: { authorized: true },
+				},
 			}),
 		).toBe(false);
-		expect(route.matcher('http://a.com/logged-in')).toBe(false);
+		expect(route.matcher({ url: 'http://a.com/logged-in' })).toBe(false);
 		expect(
-			route.matcher('http://a.com/logged-in', {
-				headers: { authorized: true },
+			route.matcher({
+				url: 'http://a.com/logged-in',
+				options: {
+					headers: { authorized: true },
+				},
 			}),
 		).toBe(true);
 	});
 
 	it('match using custom function using request body', () => {
 		const route = new Route({
-			matcherFunction: (url, opts) => opts.body === 'a string',
+			matcherFunction: (req) => {
+				return req.options.body === 'a string';
+			},
 			response: 200,
 		});
-		expect(route.matcher('http://a.com/logged-in')).toBe(false);
+		expect(route.matcher({ url: 'http://a.com/logged-in', options: {} })).toBe(
+			false,
+		);
 		expect(
-			route.matcher('http://a.com/logged-in', {
-				method: 'post',
-				body: 'a string',
+			route.matcher({
+				url: 'http://a.com/logged-in',
+				options: {
+					method: 'post',
+					body: 'a string',
+				},
 			}),
 		).toBe(true);
 	});
@@ -43,19 +56,25 @@ describe('function matching', () => {
 		const route = new Route({
 			url: 'end:profile',
 			response: 200,
-			matcherFunction: (url, opts) =>
-				opts && opts.headers && opts.headers.authorized === true,
+			matcherFunction: ({ options }) =>
+				options && options.headers && options.headers.authorized === true,
 		});
 
-		expect(route.matcher('http://a.com/profile')).toBe(false);
+		expect(route.matcher({ url: 'http://a.com/profile' })).toBe(false);
 		expect(
-			route.matcher('http://a.com/not', {
-				headers: { authorized: true },
+			route.matcher({
+				url: 'http://a.com/not',
+				options: {
+					headers: { authorized: true },
+				},
 			}),
 		).toBe(false);
 		expect(
-			route.matcher('http://a.com/profile', {
-				headers: { authorized: true },
+			route.matcher({
+				url: 'http://a.com/profile',
+				options: {
+					headers: { authorized: true },
+				},
 			}),
 		).toBe(true);
 	});
diff --git a/packages/core/src/__tests__/Matchers/header.js b/packages/core/src/__tests__/Matchers/header.js
index a95dc27..1f64b58 100644
--- a/packages/core/src/__tests__/Matchers/header.js
+++ b/packages/core/src/__tests__/Matchers/header.js
@@ -9,7 +9,7 @@ describe('header matching', () => {
 			response: 200,
 		});
 
-		expect(route.matcher('http://a.com/')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com/' })).toBe(true);
 	});
 
 	it("not match when headers don't match", () => {
@@ -20,8 +20,11 @@ describe('header matching', () => {
 		});
 
 		expect(
-			route.matcher('http://a.com/', {
-				headers: { a: 'c' },
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					headers: { a: 'c' },
+				},
 			}),
 		).toBe(false);
 	});
@@ -34,8 +37,11 @@ describe('header matching', () => {
 		});
 
 		expect(
-			route.matcher('http://a.com/', {
-				headers: { a: 'b' },
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					headers: { a: 'b' },
+				},
 			}),
 		).toBe(true);
 	});
@@ -48,8 +54,11 @@ describe('header matching', () => {
 		});
 
 		expect(
-			route.matcher('http://a.com/', {
-				headers: { A: 'b' },
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					headers: { A: 'b' },
+				},
 			}),
 		).toBe(true);
 	});
@@ -63,8 +72,11 @@ describe('header matching', () => {
 		});
 
 		expect(
-			route.matcher('http://a.com/', {
-				headers: { a: ['b', 'c'] },
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					headers: { a: ['b', 'c'] },
+				},
 			}),
 		).toBe(true);
 	});
@@ -77,8 +89,11 @@ describe('header matching', () => {
 		});
 
 		expect(
-			route.matcher('http://a.com/', {
-				headers: { a: ['b', 'c'] },
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					headers: { a: ['b', 'c'] },
+				},
 			}),
 		).toBe(false);
 	});
@@ -91,8 +106,11 @@ describe('header matching', () => {
 		});
 
 		expect(
-			route.matcher('http://a.com/', {
-				headers: { a: 'b', c: 'd' },
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					headers: { a: 'b', c: 'd' },
+				},
 			}),
 		).toBe(true);
 	});
@@ -105,8 +123,11 @@ describe('header matching', () => {
 		});
 
 		expect(
-			route.matcher('http://a.com/', {
-				headers: { a: 'b' },
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					headers: { a: 'b' },
+				},
 			}),
 		).toBe(false);
 	});
@@ -119,8 +140,11 @@ describe('header matching', () => {
 		});
 
 		expect(
-			route.matcher('http://a.com/', {
-				headers: new Headers({ a: 'b' }),
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					headers: new Headers({ a: 'b' }),
+				},
 			}),
 		).toBe(true);
 	});
@@ -132,10 +156,13 @@ describe('header matching', () => {
 			headers: { a: 'b' },
 		});
 
-		expect(route.matcher('http://domain.com/person')).toBe(false);
+		expect(route.matcher({ url: 'http://domain.com/person' })).toBe(false);
 		expect(
-			route.matcher('http://domain.com/person', {
-				headers: { a: 'b' },
+			route.matcher({
+				url: 'http://domain.com/person',
+				options: {
+					headers: { a: 'b' },
+				},
 			}),
 		).toBe(true);
 	});
@@ -162,8 +189,11 @@ describe('header matching', () => {
 		});
 
 		expect(
-			route.matcher('http://a.com', {
-				headers: new MyHeaders({ a: 'b' }),
+			route.matcher({
+				url: 'http://a.com',
+				options: {
+					headers: new MyHeaders({ a: 'b' }),
+				},
 			}),
 		).toBe(true);
 	});
diff --git a/packages/core/src/__tests__/Matchers/method.test.js b/packages/core/src/__tests__/Matchers/method.test.js
index 3d8a940..d30c820 100644
--- a/packages/core/src/__tests__/Matchers/method.test.js
+++ b/packages/core/src/__tests__/Matchers/method.test.js
@@ -6,50 +6,74 @@ describe('method matching', () => {
 	it('match any method by default', () => {
 		const route = new Route({ url: '*', response: 200 });
 
-		expect(route.matcher('http://a.com/', { method: 'GET' })).toBe(true);
-		expect(route.matcher('http://a.com/', { method: 'POST' })).toBe(true);
+		expect(
+			route.matcher({ url: 'http://a.com/', options: { method: 'GET' } }),
+		).toBe(true);
+		expect(
+			route.matcher({ url: 'http://a.com/', options: { method: 'POST' } }),
+		).toBe(true);
 	});
 
 	it('configure an exact method to match', () => {
 		const route = new Route({ method: 'POST', response: 200 });
 
-		expect(route.matcher('http://a.com/', { method: 'GET' })).toBe(false);
-		expect(route.matcher('http://a.com/', { method: 'POST' })).toBe(true);
+		expect(
+			route.matcher({ url: 'http://a.com/', options: { method: 'GET' } }),
+		).toBe(false);
+		expect(
+			route.matcher({ url: 'http://a.com/', options: { method: 'POST' } }),
+		).toBe(true);
 	});
 
 	it('match implicit GET', () => {
 		const route = new Route({ method: 'GET', response: 200 });
-		expect(route.matcher('http://a.com/')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com/' })).toBe(true);
 	});
 
 	it('be case insensitive', () => {
 		const upperCaseRoute = new Route({ method: 'POST', response: 200 });
 		const lowerCaseRoute = new Route({ method: 'post', response: 200 });
 
-		expect(upperCaseRoute.matcher('http://a.com/', { method: 'post' })).toBe(
-			true,
-		);
-		expect(upperCaseRoute.matcher('http://a.com/', { method: 'POST' })).toBe(
-			true,
-		);
-		expect(lowerCaseRoute.matcher('http://a.com/', { method: 'post' })).toBe(
-			true,
-		);
-		expect(lowerCaseRoute.matcher('http://a.com/', { method: 'POST' })).toBe(
-			true,
-		);
+		expect(
+			upperCaseRoute.matcher({
+				url: 'http://a.com/',
+				options: { method: 'post' },
+			}),
+		).toBe(true);
+		expect(
+			upperCaseRoute.matcher({
+				url: 'http://a.com/',
+				options: { method: 'POST' },
+			}),
+		).toBe(true);
+		expect(
+			lowerCaseRoute.matcher({
+				url: 'http://a.com/',
+				options: { method: 'post' },
+			}),
+		).toBe(true);
+		expect(
+			lowerCaseRoute.matcher({
+				url: 'http://a.com/',
+				options: { method: 'POST' },
+			}),
+		).toBe(true);
 	});
 
 	it('can be used alongside function matchers', () => {
 		const route = new Route({
 			method: 'POST',
-			matcherFunction: (url) => /a\.com/.test(url),
+			matcherFunction: ({ url }) => /a\.com/.test(url),
 
 			response: 200,
 		});
 
-		expect(route.matcher('http://a.com')).toBe(false);
-		expect(route.matcher('http://b.com', { method: 'POST' })).toBe(false);
-		expect(route.matcher('http://a.com', { method: 'POST' })).toBe(true);
+		expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+		expect(
+			route.matcher({ url: 'http://b.com', options: { method: 'POST' } }),
+		).toBe(false);
+		expect(
+			route.matcher({ url: 'http://a.com', options: { method: 'POST' } }),
+		).toBe(true);
 	});
 });
diff --git a/packages/core/src/__tests__/Matchers/query-string.test.js b/packages/core/src/__tests__/Matchers/query-string.test.js
index 127a374..363c301 100644
--- a/packages/core/src/__tests__/Matchers/query-string.test.js
+++ b/packages/core/src/__tests__/Matchers/query-string.test.js
@@ -8,8 +8,8 @@ describe('query string matching', () => {
 			response: 200,
 		});
 
-		expect(route.matcher('http://a.com')).toBe(false);
-		expect(route.matcher('http://a.com?a=b&c=d')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+		expect(route.matcher({ url: 'http://a.com?a=b&c=d' })).toBe(true);
 	});
 
 	it('match a query string against a URL object', () => {
@@ -20,7 +20,7 @@ describe('query string matching', () => {
 		const url = new URL('http://a.com/path');
 		url.searchParams.append('a', 'b');
 		url.searchParams.append('c', 'd');
-		expect(route.matcher(url)).toBe(true);
+		expect(route.matcher({ url })).toBe(true);
 	});
 
 	it('match a query string against a relative path', () => {
@@ -29,7 +29,7 @@ describe('query string matching', () => {
 			response: 200,
 		});
 		const url = '/path?a=b';
-		expect(route.matcher(url)).toBe(true);
+		expect(route.matcher({ url })).toBe(true);
 	});
 
 	it('match multiple query strings', () => {
@@ -38,10 +38,10 @@ describe('query string matching', () => {
 			response: 200,
 		});
 
-		expect(route.matcher('http://a.com')).toBe(false);
-		expect(route.matcher('http://a.com?a=b')).toBe(false);
-		expect(route.matcher('http://a.com?a=b&c=d')).toBe(true);
-		expect(route.matcher('http://a.com?c=d&a=b')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+		expect(route.matcher({ url: 'http://a.com?a=b' })).toBe(false);
+		expect(route.matcher({ url: 'http://a.com?a=b&c=d' })).toBe(true);
+		expect(route.matcher({ url: 'http://a.com?c=d&a=b' })).toBe(true);
 	});
 
 	it('ignore irrelevant query strings', () => {
@@ -50,7 +50,7 @@ describe('query string matching', () => {
 			response: 200,
 		});
 
-		expect(route.matcher('http://a.com?a=b&c=d&e=f')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com?a=b&c=d&e=f' })).toBe(true);
 	});
 	it('match an empty query string', () => {
 		const route = new Route({
@@ -58,8 +58,8 @@ describe('query string matching', () => {
 			response: 200,
 		});
 
-		expect(route.matcher('http://a.com')).toBe(false);
-		expect(route.matcher('http://a.com?a=')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+		expect(route.matcher({ url: 'http://a.com?a=' })).toBe(true);
 	});
 
 	describe('value coercion', () => {
@@ -70,8 +70,8 @@ describe('query string matching', () => {
 				},
 				response: 200,
 			});
-			expect(route.matcher('http://a.com')).toBe(false);
-			expect(route.matcher('http://a.com?a=1')).toBe(true);
+			expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+			expect(route.matcher({ url: 'http://a.com?a=1' })).toBe(true);
 		});
 
 		it('coerce floats to strings and match', () => {
@@ -81,8 +81,8 @@ describe('query string matching', () => {
 				},
 				response: 200,
 			});
-			expect(route.matcher('http://a.com')).toBe(false);
-			expect(route.matcher('http://a.com?a=1.2')).toBe(true);
+			expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+			expect(route.matcher({ url: 'http://a.com?a=1.2' })).toBe(true);
 		});
 
 		it('coerce booleans to strings and match', () => {
@@ -99,10 +99,10 @@ describe('query string matching', () => {
 				response: 200,
 			});
 
-			expect(trueRoute.matcher('http://a.com')).toBe(false);
-			expect(falseRoute.matcher('http://a.com')).toBe(false);
-			expect(trueRoute.matcher('http://a.com?a=true')).toBe(true);
-			expect(falseRoute.matcher('http://a.com?b=false')).toBe(true);
+			expect(trueRoute.matcher({ url: 'http://a.com' })).toBe(false);
+			expect(falseRoute.matcher({ url: 'http://a.com' })).toBe(false);
+			expect(trueRoute.matcher({ url: 'http://a.com?a=true' })).toBe(true);
+			expect(falseRoute.matcher({ url: 'http://a.com?b=false' })).toBe(true);
 		});
 
 		it('coerce undefined to an empty string and match', () => {
@@ -112,8 +112,8 @@ describe('query string matching', () => {
 				},
 				response: 200,
 			});
-			expect(route.matcher('http://a.com')).toBe(false);
-			expect(route.matcher('http://a.com?a=')).toBe(true);
+			expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+			expect(route.matcher({ url: 'http://a.com?a=' })).toBe(true);
 		});
 
 		it('coerce null to an empty string and match', () => {
@@ -123,8 +123,8 @@ describe('query string matching', () => {
 				},
 				response: 200,
 			});
-			expect(route.matcher('http://a.com')).toBe(false);
-			expect(route.matcher('http://a.com?a=')).toBe(true);
+			expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+			expect(route.matcher({ url: 'http://a.com?a=' })).toBe(true);
 		});
 
 		it('coerce an object to an empty string and match', () => {
@@ -134,8 +134,8 @@ describe('query string matching', () => {
 				},
 				response: 200,
 			});
-			expect(route.matcher('http://a.com')).toBe(false);
-			expect(route.matcher('http://a.com?a=')).toBe(true);
+			expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+			expect(route.matcher({ url: 'http://a.com?a=' })).toBe(true);
 		});
 
 		it('can match a query string with different value types', () => {
@@ -150,9 +150,11 @@ describe('query string matching', () => {
 				},
 			});
 
-			expect(route.matcher('http://a.com')).toBe(false);
+			expect(route.matcher({ url: 'http://a.com' })).toBe(false);
 			expect(
-				route.matcher('http://a.com?t=true&f=false&u=&num=1&arr=a&arr='),
+				route.matcher({
+					url: 'http://a.com?t=true&f=false&u=&num=1&arr=a&arr=',
+				}),
 			).toBe(true);
 		});
 	});
@@ -162,26 +164,26 @@ describe('query string matching', () => {
 		it('match repeated query strings', () => {
 			const route = new Route({ query: { a: ['b', 'c'] }, response: 200 });
 
-			expect(route.matcher('http://a.com')).toBe(false);
-			expect(route.matcher('http://a.com?a=b')).toBe(false);
-			expect(route.matcher('http://a.com?a=b&a=c')).toBe(true);
-			expect(route.matcher('http://a.com?a=b&a=c&a=d')).toBe(false);
+			expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+			expect(route.matcher({ url: 'http://a.com?a=b' })).toBe(false);
+			expect(route.matcher({ url: 'http://a.com?a=b&a=c' })).toBe(true);
+			expect(route.matcher({ url: 'http://a.com?a=b&a=c&a=d' })).toBe(false);
 		});
 
 		it('match repeated query strings in any order', () => {
 			const route = new Route({ query: { a: ['b', 'c'] }, response: 200 });
 
-			expect(route.matcher('http://a.com')).toBe(false);
-			expect(route.matcher('http://a.com?a=b&a=c')).toBe(true);
-			expect(route.matcher('http://a.com?a=c&a=b')).toBe(true);
+			expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+			expect(route.matcher({ url: 'http://a.com?a=b&a=c' })).toBe(true);
+			expect(route.matcher({ url: 'http://a.com?a=c&a=b' })).toBe(true);
 		});
 
 		it('match a query string array of length 1', () => {
 			const route = new Route({ query: { a: ['b'] }, response: 200 });
 
-			expect(route.matcher('http://a.com')).toBe(false);
-			expect(route.matcher('http://a.com?a=b')).toBe(true);
-			expect(route.matcher('http://a.com?a=b&a=c')).toBe(false);
+			expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+			expect(route.matcher({ url: 'http://a.com?a=b' })).toBe(true);
+			expect(route.matcher({ url: 'http://a.com?a=b&a=c' })).toBe(false);
 		});
 
 		it('match a repeated query string with an empty value', () => {
@@ -190,9 +192,9 @@ describe('query string matching', () => {
 				response: 200,
 			});
 
-			expect(route.matcher('http://a.com')).toBe(false);
-			expect(route.matcher('http://a.com?a=b')).toBe(false);
-			expect(route.matcher('http://a.com?a=b&a=')).toBe(true);
+			expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+			expect(route.matcher({ url: 'http://a.com?a=b' })).toBe(false);
+			expect(route.matcher({ url: 'http://a.com?a=b&a=' })).toBe(true);
 		});
 	});
 
@@ -205,10 +207,10 @@ describe('query string matching', () => {
 				query: { a: 'b' },
 			});
 
-			expect(route.matcher('http://a.com?c=d')).toBe(false);
-			expect(route.matcher('http://a.com?a=b')).toBe(false);
-			expect(route.matcher('http://a.com?c=d&a=b')).toBe(true);
-			expect(route.matcher('http://a.com?a=b&c=d')).toBe(true);
+			expect(route.matcher({ url: 'http://a.com?c=d' })).toBe(false);
+			expect(route.matcher({ url: 'http://a.com?a=b' })).toBe(false);
+			expect(route.matcher({ url: 'http://a.com?c=d&a=b' })).toBe(true);
+			expect(route.matcher({ url: 'http://a.com?a=b&c=d' })).toBe(true);
 		});
 
 		it('can be used alongside function matchers', () => {
@@ -218,8 +220,8 @@ describe('query string matching', () => {
 				query: { a: 'b' },
 			});
 
-			expect(route.matcher('http://a.com')).toBe(false);
-			expect(route.matcher('http://a.com?a=b')).toBe(true);
+			expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+			expect(route.matcher({ url: 'http://a.com?a=b' })).toBe(true);
 		});
 	});
 });
diff --git a/packages/core/src/__tests__/Matchers/route-config-object.test.js b/packages/core/src/__tests__/Matchers/route-config-object.test.js
index fff37d0..b9b7c4c 100644
--- a/packages/core/src/__tests__/Matchers/route-config-object.test.js
+++ b/packages/core/src/__tests__/Matchers/route-config-object.test.js
@@ -6,31 +6,37 @@ import Route from '../../Route.js';
 describe('matcher object', () => {
 	it('use matcher object with matcher property', () => {
 		const route = new Route({ url: 'http://a.com', response: 200 });
-		expect(route.matcher('http://a.com')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com' })).toBe(true);
 	});
 
 	it('use matcher object with url property', () => {
 		const route = new Route({ url: 'http://a.com', response: 200 });
-		expect(route.matcher('http://a.com')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com' })).toBe(true);
 	});
 
 	it('can use function and url simultaneously', () => {
 		const route = new Route({
 			url: 'end:path',
-			matcherFunction: (url, opts) =>
-				opts && opts.headers && opts.headers.authorized === true,
+			matcherFunction: ({ options }) =>
+				options && options.headers && options.headers.authorized === true,
 			response: 200,
 		});
 
-		expect(route.matcher('http://a.com/path')).toBe(false);
+		expect(route.matcher({ url: 'http://a.com/path' })).toBe(false);
 		expect(
-			route.matcher('http://a.com', {
-				headers: { authorized: true },
+			route.matcher({
+				url: 'http://a.com',
+				options: {
+					headers: { authorized: true },
+				},
 			}),
 		).toBe(false);
 		expect(
-			route.matcher('http://a.com/path', {
-				headers: { authorized: true },
+			route.matcher({
+				url: 'http://a.com/path',
+				options: {
+					headers: { authorized: true },
+				},
 			}),
 		).toBe(true);
 	});
@@ -38,7 +44,7 @@ describe('matcher object', () => {
 	// TODO this shoudl probably be an error
 	it.skip('if no url provided, match any url', () => {
 		const route = new Route({ response: 200 });
-		expect(route.matcher('http://a.com')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com' })).toBe(true);
 	});
 
 	//TODO be stronger on discouraging this
@@ -59,13 +65,19 @@ describe('matcher object', () => {
 		});
 
 		expect(
-			route.matcher('http://a.com', {
-				headers: { a: 'c' },
+			route.matcher({
+				url: 'http://a.com',
+				options: {
+					headers: { a: 'c' },
+				},
 			}),
 		).toBe(false);
 		expect(
-			route.matcher('http://a.com', {
-				headers: { a: 'b' },
+			route.matcher({
+				url: 'http://a.com',
+				options: {
+					headers: { a: 'b' },
+				},
 			}),
 		).toBe(true);
 	});
@@ -77,8 +89,8 @@ describe('matcher object', () => {
 			response: 200,
 		});
 
-		expect(route.matcher('http://a.com')).toBe(false);
-		expect(route.matcher('http://a.com?a=b')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com' })).toBe(false);
+		expect(route.matcher({ url: 'http://a.com?a=b' })).toBe(true);
 	});
 
 	it('can match path parameter', () => {
@@ -87,30 +99,40 @@ describe('matcher object', () => {
 			params: { var: 'b' },
 			response: 200,
 		});
-		expect(route.matcher('/')).toBe(false);
-		expect(route.matcher('/type/a')).toBe(false);
-		expect(route.matcher('/type/b')).toBe(true);
+		expect(route.matcher({ url: '/' })).toBe(false);
+		expect(route.matcher({ url: '/type/a' })).toBe(false);
+		expect(route.matcher({ url: '/type/b' })).toBe(true);
 	});
 
 	it('can match method', () => {
 		const route = new Route({ method: 'POST', response: 200 });
-		expect(route.matcher('http://a.com', { method: 'GET' })).toBe(false);
-		expect(route.matcher('http://a.com', { method: 'POST' })).toBe(true);
+		expect(
+			route.matcher({ url: 'http://a.com', options: { method: 'GET' } }),
+		).toBe(false);
+		expect(
+			route.matcher({ url: 'http://a.com', options: { method: 'POST' } }),
+		).toBe(true);
 	});
 
 	it('can match body', () => {
 		const route = new Route({ body: { foo: 'bar' }, response: 200 });
 
 		expect(
-			route.matcher('http://a.com', {
-				method: 'POST',
+			route.matcher({
+				url: 'http://a.com',
+				options: {
+					method: 'POST',
+				},
 			}),
 		).toBe(false);
 		expect(
-			route.matcher('http://a.com', {
-				method: 'POST',
-				body: JSON.stringify({ foo: 'bar' }),
-				headers: { 'Content-Type': 'application/json' },
+			route.matcher({
+				url: 'http://a.com',
+				options: {
+					method: 'POST',
+					body: JSON.stringify({ foo: 'bar' }),
+					headers: { 'Content-Type': 'application/json' },
+				},
 			}),
 		).toBe(true);
 	});
@@ -122,9 +144,12 @@ describe('matcher object', () => {
 			response: 200,
 		});
 		expect(
-			route.matcher('http://a.com', {
-				method: 'POST',
-				body: JSON.stringify({ a: 1, b: 2 }),
+			route.matcher({
+				url: 'http://a.com',
+				options: {
+					method: 'POST',
+					body: JSON.stringify({ a: 1, b: 2 }),
+				},
 			}),
 		).toBe(true);
 	});
diff --git a/packages/core/src/__tests__/Matchers/url.test.js b/packages/core/src/__tests__/Matchers/url.test.js
index 1178cad..938efb4 100644
--- a/packages/core/src/__tests__/Matchers/url.test.js
+++ b/packages/core/src/__tests__/Matchers/url.test.js
@@ -4,35 +4,35 @@ import Route from '../../Route.js';
 describe('url matching', () => {
 	it('match exact strings', () => {
 		const route = new Route({ url: 'http://a.com/path', response: 200 });
-		expect(route.matcher('http://a.com/pat')).toBe(false);
-		expect(route.matcher('http://a.com/paths')).toBe(false);
-		expect(route.matcher('http://a.co/path')).toBe(false);
-		expect(route.matcher('http://a.com/path')).toBe(true);
-		expect(route.matcher('//a.com/path')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com/pat' })).toBe(false);
+		expect(route.matcher({ url: 'http://a.com/paths' })).toBe(false);
+		expect(route.matcher({ url: 'http://a.co/path' })).toBe(false);
+		expect(route.matcher({ url: 'http://a.com/path' })).toBe(true);
+		expect(route.matcher({ url: '//a.com/path' })).toBe(true);
 	});
 
 	it('match string objects', () => {
 		const route = new Route({ url: 'http://a.com/path', response: 200 });
-		expect(route.matcher(new String('http://a.com/path'))).toBe(true); // eslint-disable-line no-new-wrappers
+		expect(route.matcher({ url: new String('http://a.com/path') })).toBe(true); // eslint-disable-line no-new-wrappers
 	});
 
 	it('match exact strings with relative url', () => {
 		const route = new Route({ url: '/path', response: 200 });
-		expect(route.matcher('/pat')).toBe(false);
-		expect(route.matcher('/paths')).toBe(false);
-		expect(route.matcher('/path')).toBe(true);
+		expect(route.matcher({ url: '/pat' })).toBe(false);
+		expect(route.matcher({ url: '/paths' })).toBe(false);
+		expect(route.matcher({ url: '/path' })).toBe(true);
 	});
 
 	it('match exact string against URL object', () => {
 		const route = new Route({ url: 'http://a.com/path', response: 200 });
 		const url = new URL('http://a.com/path');
-		expect(route.matcher(url)).toBe(true);
+		expect(route.matcher({ url })).toBe(true);
 	});
 
 	it('match using URL object as matcher', () => {
 		const url = new URL('http://a.com/path');
 		const route = new Route({ url: url, response: 200 });
-		expect(route.matcher('http://a.com/path')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com/path' })).toBe(true);
 	});
 
 	it('match begin: keyword', () => {
@@ -41,86 +41,86 @@ describe('url matching', () => {
 			response: 200,
 		});
 
-		expect(route.matcher('http://b.com/path')).toBe(false);
-		expect(route.matcher('http://a.com/pat')).toBe(false);
-		expect(route.matcher('http://a.com/path')).toBe(true);
-		expect(route.matcher('http://a.com/paths')).toBe(true);
+		expect(route.matcher({ url: 'http://b.com/path' })).toBe(false);
+		expect(route.matcher({ url: 'http://a.com/pat' })).toBe(false);
+		expect(route.matcher({ url: 'http://a.com/path' })).toBe(true);
+		expect(route.matcher({ url: 'http://a.com/paths' })).toBe(true);
 	});
 
 	it('match end: keyword', () => {
 		const route = new Route({ url: 'end:com/path', response: 200 });
-		expect(route.matcher('http://a.com/paths')).toBe(false);
-		expect(route.matcher('http://a.com/pat')).toBe(false);
-		expect(route.matcher('http://a.com/path')).toBe(true);
-		expect(route.matcher('http://b.com/path')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com/paths' })).toBe(false);
+		expect(route.matcher({ url: 'http://a.com/pat' })).toBe(false);
+		expect(route.matcher({ url: 'http://a.com/path' })).toBe(true);
+		expect(route.matcher({ url: 'http://b.com/path' })).toBe(true);
 	});
 
 	it('match glob: keyword', () => {
 		const route = new Route({ url: 'glob:/its/*/*', response: 200 });
-		expect(route.matcher('/its/alive')).toBe(false);
-		expect(route.matcher('/its/a/boy')).toBe(true);
-		expect(route.matcher('/its/a/girl')).toBe(true);
+		expect(route.matcher({ url: '/its/alive' })).toBe(false);
+		expect(route.matcher({ url: '/its/a/boy' })).toBe(true);
+		expect(route.matcher({ url: '/its/a/girl' })).toBe(true);
 	});
 
 	it('match express: keyword', () => {
 		const route = new Route({ url: 'express:/its/:word', response: 200 });
 
-		expect(route.matcher('/its')).toBe(false);
-		expect(route.matcher('/its/')).toBe(false);
-		expect(route.matcher('/its/a/girl')).toBe(false);
-		expect(route.matcher('/its/alive')).toBe(true);
+		expect(route.matcher({ url: '/its' })).toBe(false);
+		expect(route.matcher({ url: '/its/' })).toBe(false);
+		expect(route.matcher({ url: '/its/a/girl' })).toBe(false);
+		expect(route.matcher({ url: '/its/alive' })).toBe(true);
 	});
 
 	it('match path: keyword', () => {
 		const route = new Route({ url: 'path:/its/:word', response: 200 });
 
-		expect(route.matcher('/its/boy')).toBe(false);
-		expect(route.matcher('/its/:word/still')).toBe(false);
-		expect(route.matcher('/its/:word')).toBe(true);
-		expect(route.matcher('/its/:word?brain=false')).toBe(true);
+		expect(route.matcher({ url: '/its/boy' })).toBe(false);
+		expect(route.matcher({ url: '/its/:word/still' })).toBe(false);
+		expect(route.matcher({ url: '/its/:word' })).toBe(true);
+		expect(route.matcher({ url: '/its/:word?brain=false' })).toBe(true);
 	});
 
 	it('match wildcard string', () => {
 		const route = new Route({ url: '*', response: 200 });
 
-		expect(route.matcher('http://a.com')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com' })).toBe(true);
 	});
 
 	it('match regular expressions', () => {
 		const rx = /http\:\/\/a\.com\/\d+/;
 		const route = new Route({ url: rx, response: 200 });
 
-		expect(route.matcher('http://a.com/')).toBe(false);
-		expect(route.matcher('http://a.com/abcde')).toBe(false);
-		expect(route.matcher('http://a.com/12345')).toBe(true);
+		expect(route.matcher({ url: 'http://a.com/' })).toBe(false);
+		expect(route.matcher({ url: 'http://a.com/abcde' })).toBe(false);
+		expect(route.matcher({ url: 'http://a.com/12345' })).toBe(true);
 	});
 
 	it('match relative urls', () => {
 		const route = new Route({ url: '/a.com/', response: 200 });
-		expect(route.matcher('/a.com/')).toBe(true);
+		expect(route.matcher({ url: '/a.com/' })).toBe(true);
 	});
 
 	it('match relative urls with dots', () => {
 		const route = new Route({ url: '/it.at/there/', response: 200 });
-		expect(route.matcher('/it.at/not/../there/')).toBe(true);
-		expect(route.matcher('./it.at/there/')).toBe(true);
+		expect(route.matcher({ url: '/it.at/not/../there/' })).toBe(true);
+		expect(route.matcher({ url: './it.at/there/' })).toBe(true);
 	});
 
 	it('match absolute urls with dots', () => {
 		const route = new Route({ url: 'http://it.at/there/', response: 200 });
-		expect(route.matcher('http://it.at/not/../there/')).toBe(true);
+		expect(route.matcher({ url: 'http://it.at/not/../there/' })).toBe(true);
 	});
 
 	describe('host normalisation', () => {
 		it('match exact pathless urls regardless of trailing slash', () => {
 			const route = new Route({ url: 'http://a.com/', response: 200 });
 
-			expect(route.matcher('http://a.com/')).toBe(true);
-			expect(route.matcher('http://a.com')).toBe(true);
+			expect(route.matcher({ url: 'http://a.com/' })).toBe(true);
+			expect(route.matcher({ url: 'http://a.com' })).toBe(true);
 
 			const route2 = new Route({ url: 'http://b.com', response: 200 });
-			expect(route2.matcher('http://b.com/')).toBe(true);
-			expect(route2.matcher('http://b.com')).toBe(true);
+			expect(route2.matcher({ url: 'http://b.com/' })).toBe(true);
+			expect(route2.matcher({ url: 'http://b.com' })).toBe(true);
 		});
 	});
 
@@ -130,10 +130,10 @@ describe('url matching', () => {
 				url: 'data:text/plain,path',
 				response: 200,
 			});
-			expect(route.matcher('data:text/plain,pat')).toBe(false);
-			expect(route.matcher('data:text/plain,paths')).toBe(false);
-			expect(route.matcher('data:text/html,path')).toBe(false);
-			expect(route.matcher('data:text/plain,path')).toBe(true);
+			expect(route.matcher({ url: 'data:text/plain,pat' })).toBe(false);
+			expect(route.matcher({ url: 'data:text/plain,paths' })).toBe(false);
+			expect(route.matcher({ url: 'data:text/html,path' })).toBe(false);
+			expect(route.matcher({ url: 'data:text/plain,path' })).toBe(true);
 		});
 		it('match exact string against URL object', () => {
 			const route = new Route({
@@ -141,46 +141,50 @@ describe('url matching', () => {
 				response: 200,
 			});
 			const url = new URL('data:text/plain,path');
-			expect(route.matcher(url)).toBe(true);
+			expect(route.matcher({ url })).toBe(true);
 		});
 		it('match using URL object as matcher', () => {
 			const url = new URL('data:text/plain,path');
 			const route = new Route({ url: url, response: 200 });
-			expect(route.matcher('data:text/plain,path')).toBe(true);
+			expect(route.matcher({ url: 'data:text/plain,path' })).toBe(true);
 		});
 		it('match begin: keyword', () => {
 			const route = new Route({
 				url: 'begin:data:text/plain',
 				response: 200,
 			});
-			expect(route.matcher('http://a.com/path')).toBe(false);
-			expect(route.matcher('data:text/html,path')).toBe(false);
-			expect(route.matcher('data:text/plain,path')).toBe(true);
-			expect(route.matcher('data:text/plain;base64,cGF0aA')).toBe(true);
+			expect(route.matcher({ url: 'http://a.com/path' })).toBe(false);
+			expect(route.matcher({ url: 'data:text/html,path' })).toBe(false);
+			expect(route.matcher({ url: 'data:text/plain,path' })).toBe(true);
+			expect(route.matcher({ url: 'data:text/plain;base64,cGF0aA' })).toBe(
+				true,
+			);
 		});
 		it('match end: keyword', () => {
 			const route = new Route({ url: 'end:sky', response: 200 });
-			expect(route.matcher('data:text/plain,blue lake')).toBe(false);
-			expect(route.matcher('data:text/plain,blue sky research')).toBe(false);
-			expect(route.matcher('data:text/plain,blue sky')).toBe(true);
-			expect(route.matcher('data:text/plain,grey sky')).toBe(true);
+			expect(route.matcher({ url: 'data:text/plain,blue lake' })).toBe(false);
+			expect(route.matcher({ url: 'data:text/plain,blue sky research' })).toBe(
+				false,
+			);
+			expect(route.matcher({ url: 'data:text/plain,blue sky' })).toBe(true);
+			expect(route.matcher({ url: 'data:text/plain,grey sky' })).toBe(true);
 		});
 		it('match glob: keyword', () => {
 			const route = new Route({ url: 'glob:data:* sky', response: 200 });
-			expect(route.matcher('data:text/plain,blue lake')).toBe(false);
-			expect(route.matcher('data:text/plain,blue sky')).toBe(true);
-			expect(route.matcher('data:text/plain,grey sky')).toBe(true);
+			expect(route.matcher({ url: 'data:text/plain,blue lake' })).toBe(false);
+			expect(route.matcher({ url: 'data:text/plain,blue sky' })).toBe(true);
+			expect(route.matcher({ url: 'data:text/plain,grey sky' })).toBe(true);
 		});
 		it('match wildcard string', () => {
 			const route = new Route({ url: '*', response: 200 });
-			expect(route.matcher('data:text/plain,path')).toBe(true);
+			expect(route.matcher({ url: 'data:text/plain,path' })).toBe(true);
 		});
 		it('match regular expressions', () => {
 			const rx = /data\:text\/plain,\d+/;
 			const route = new Route({ url: rx, response: 200 });
-			expect(route.matcher('data:text/html,12345')).toBe(false);
-			expect(route.matcher('data:text/plain,path')).toBe(false);
-			expect(route.matcher('data:text/plain,12345')).toBe(true);
+			expect(route.matcher({ url: 'data:text/html,12345' })).toBe(false);
+			expect(route.matcher({ url: 'data:text/plain,path' })).toBe(false);
+			expect(route.matcher({ url: 'data:text/plain,12345' })).toBe(true);
 		});
 	});
 });
diff --git a/packages/core/src/__tests__/router-integration.test.js b/packages/core/src/__tests__/router-integration.test.js
index bf6a430..b892e87 100644
--- a/packages/core/src/__tests__/router-integration.test.js
+++ b/packages/core/src/__tests__/router-integration.test.js
@@ -15,7 +15,7 @@ describe('Router', () => {
 
 		it('match using custom function with Request', async () => {
 			const fm = fetchMock.createInstance();
-			fm.route((url, options) => {
+			fm.route(({ url, options }) => {
 				return url.indexOf('logged-in') > -1 && options.headers.authorized;
 			}, 200);
 
@@ -37,10 +37,7 @@ describe('Router', () => {
 			const valueToSet = propertyToCheck === 'credentials' ? 'include' : false;
 
 			const fm = fetchMock.createInstance();
-			fm.route(
-				(url, options, request) => request[propertyToCheck] === valueToSet,
-				200,
-			);
+			fm.route(({ request }) => request[propertyToCheck] === valueToSet, 200);
 
 			await expect(
 				fm.fetchHandler(new Request('http://a.com/logged-in')),
@@ -59,7 +56,10 @@ describe('Router', () => {
 			const fm = fetchMock.createInstance();
 			fm.defineMatcher({
 				name: 'syncMatcher',
-				matcher: (route) => (url) => url.indexOf(route.syncMatcher) > -1,
+				matcher:
+					(route) =>
+					({ url }) =>
+						url.indexOf(route.syncMatcher) > -1,
 			});
 			fm.route(
 				{
@@ -77,8 +77,10 @@ describe('Router', () => {
 			const fm = fetchMock.createInstance();
 			fm.defineMatcher({
 				name: 'bodyMatcher',
-				matcher: (route) => (url, options) =>
-					JSON.parse(options.body)[route.bodyMatcher] === true,
+				matcher:
+					(route) =>
+					({ options }) =>
+						JSON.parse(options.body)[route.bodyMatcher] === true,
 				usesBody: true,
 			});
 			fm.route(
@@ -115,8 +117,10 @@ describe('Router', () => {
 			const fm = fetchMock.createInstance();
 			fm.defineMatcher({
 				name: 'asyncBodyMatcher',
-				matcher: (route) => (url, options) =>
-					JSON.parse(options.body)[route.asyncBodyMatcher] === true,
+				matcher:
+					(route) =>
+					({ options }) =>
+						JSON.parse(options.body)[route.asyncBodyMatcher] === true,
 			});
 			fm.route(
 				{