Repository: wheresrhys/fetch-mock (js)

Issue:
Allow access to `express`-style path parameters in mocked endpoints
When adding a mock for an `express`-style path (e.g. `express:/example/:foo/:bar`, there is no built-in way to access the parsed values for `foo` and `bar`:

```
const mockedEndpoint = (url, opts) => {
  // we should be able to use { foo: <whatever>, bar: <whatever> } here without additional parsing
};

fetchMock.get('express:/example/:foo/:bar', mockedEndpoint);

fetch('/example/foovalue/barvalue'); // we would have access to { foo: 'foovalue', bar: 'barvalue' } in mockedEndpoint 
```

We're giving `fetchMock.get` enough information (the matching `express`-style pattern and the URL itself) that it should be able to parse out what `foo` and `bar` are and pass them to the callback (potentially as a third argument?).

This issue was called out in https://github.com/wheresrhys/fetch-mock/issues/300 but either wasn't addressed at all or was addressed but in a too-opaque-for-me-to-understand way.

Pull request description:
feat: can now access express parameters in responses
Fixes #656 

Patch:
diff --git a/docs/docs/@fetch-mock/core/CallHistory.md b/docs/docs/@fetch-mock/core/CallHistory.md
index 2037bc0..df4922e 100644
--- a/docs/docs/@fetch-mock/core/CallHistory.md
+++ b/docs/docs/@fetch-mock/core/CallHistory.md
@@ -10,11 +10,13 @@ sidebar_position: 4
 
 Calls are recorded, and returned, in a standard format with the following properties:
 
+- `[string|Request,Object]` - the original arguments passed in to `fetch`
 - `{string} url` - The url being fetched
 - `{NormalizedRequestOptions} options` - The options passed in to the fetch (may be derived from a `Request` if one was used)
 - `{Request} [request]` - The `Request` passed to fetch, if one was used
 - `{Route} [route]` - The route used to handle the request
 - `{Response} [response]` - The `Response` returned to the user
+- `{Object.<string,string>}` - Any express parameters extracted from the `url`
 - `{Promise<any>[]} pendingPromises` - An internal structure used by the `.flush()` method documented below
 
 ## Filtering
diff --git a/docs/docs/@fetch-mock/core/route/matcher.md b/docs/docs/@fetch-mock/core/route/matcher.md
index ac7abe0..1e24eec 100644
--- a/docs/docs/@fetch-mock/core/route/matcher.md
+++ b/docs/docs/@fetch-mock/core/route/matcher.md
@@ -78,6 +78,10 @@ When the `express:` keyword is used in a string matcher, it can be combined with
 }
 ```
 
+The values of express parameters are made available in the `expressParams` property when 
+- [Inspecting call history](/fetch-mock/docs/@fetch-mock/core/CallHistory#calllog-schema)
+- [Using a function to construct a response](/fetch-mock/docs/@fetch-mock/core/route/response#function)
+
 ## Other matching criteria
 
 ### method
diff --git a/packages/core/src/Matchers.js b/packages/core/src/Matchers.js
index 19427e6..0a8145d 100644
--- a/packages/core/src/Matchers.js
+++ b/packages/core/src/Matchers.js
@@ -61,7 +61,21 @@ const stringMatchers = {
 	},
 	express: (targetString) => {
 		const urlRX = regexparam.parse(targetString);
-		return ({ url }) => urlRX.pattern.test(getPath(url));
+		return (callLog) => {
+			const vals = urlRX.pattern.exec(getPath(callLog.url));
+			if (!vals) {
+				callLog.expressParams = {};
+				return false;
+			}
+			vals.shift();
+			/** @type {Object.<string,string>} */
+			callLog.expressParams = urlRX.keys.reduce(
+				(map, paramName, i) =>
+					vals[i] ? Object.assign(map, { [paramName]: vals[i] }) : map,
+				{},
+			);
+			return true;
+		};
 	},
 	path:
 		(targetString) =>
@@ -124,30 +138,21 @@ const getQueryStringMatcher = ({ query: passedQuery }) => {
 /**
  * @type {MatcherGenerator}
  */
-const getParamsMatcher = ({ params: expectedParams, url: matcherUrl }) => {
+const getParamsMatcher = ({ params: expectedParams, url }) => {
 	if (!expectedParams) {
 		return;
 	}
-	if (typeof matcherUrl === 'string') {
-		if (!/express:/.test(matcherUrl)) {
-			throw new Error(
-				'fetch-mock: matching on params is only possible when using an express: matcher',
-			);
-		}
-		const expectedKeys = Object.keys(expectedParams);
-		const re = regexparam.parse(matcherUrl.replace(/^express:/, ''));
-		return ({ url }) => {
-			const vals = re.pattern.exec(getPath(url)) || [];
-			vals.shift();
-			/** @type {Object.<string,string>} */
-			const params = re.keys.reduce(
-				(map, paramName, i) =>
-					vals[i] ? Object.assign(map, { [paramName]: vals[i] }) : map,
-				{},
-			);
-			return expectedKeys.every((key) => params[key] === expectedParams[key]);
-		};
+	if (!(typeof url === 'string' && /express:/.test(url))) {
+		throw new Error(
+			'fetch-mock: matching on params is only possible when using an express: matcher',
+		);
 	}
+	const expectedKeys = Object.keys(expectedParams);
+	return ({ expressParams = {} }) => {
+		return expectedKeys.every(
+			(key) => expressParams[key] === expectedParams[key],
+		);
+	};
 };
 /**
  * @type {MatcherGenerator}
@@ -238,11 +243,11 @@ const getUrlMatcher = (route) => {
 
 /** @type {MatcherDefinition[]} */
 export const builtInMatchers = [
+	{ name: 'url', matcher: getUrlMatcher },
 	{ name: 'query', matcher: getQueryStringMatcher },
 	{ name: 'method', matcher: getMethodMatcher },
 	{ name: 'headers', matcher: getHeaderMatcher },
 	{ name: 'params', matcher: getParamsMatcher },
 	{ name: 'body', matcher: getBodyMatcher, usesBody: true },
 	{ name: 'matcherFunction', matcher: getFunctionMatcher },
-	{ name: 'url', matcher: getUrlMatcher },
 ];


Test patch:
diff --git a/packages/core/src/__tests__/Matchers/express.test.js b/packages/core/src/__tests__/Matchers/express.test.js
index 678799e..27d31e9 100644
--- a/packages/core/src/__tests__/Matchers/express.test.js
+++ b/packages/core/src/__tests__/Matchers/express.test.js
@@ -44,4 +44,37 @@ describe('express path parameter matching', () => {
 			true,
 		);
 	});
+
+	it('can match based on the existence, not value, of a parameter', () => {
+		const route = new Route({
+			url: 'express:/type/:instance',
+			response: 200,
+		});
+		expect(route.matcher({ url: '/nottype/a' })).toBe(false);
+		expect(route.matcher({ url: '/type/a' })).toBe(true);
+	});
+	it('writes parameter values to the callLog', () => {
+		const route = new Route({
+			url: 'express:/type/:instance',
+			response: 200,
+			params: { instance: 'b' },
+		});
+		const callLog = { url: '/type/a' };
+		route.matcher(callLog);
+		expect(callLog.expressParams).toEqual({ instance: 'a' });
+
+		const callLog2 = { url: '/type/b' };
+		route.matcher(callLog2);
+		expect(callLog2.expressParams).toEqual({ instance: 'b' });
+	});
+
+	it('writes parameter values to the callLog even if not matched on', () => {
+		const route = new Route({
+			url: 'express:/type/:instance',
+			response: 200,
+		});
+		const callLog = { url: '/type/a' };
+		route.matcher(callLog);
+		expect(callLog.expressParams).toEqual({ instance: 'a' });
+	});
 });