Skip to content

Commit 79afa72

Browse files
committed
implement uuid in query param matcher
The old logic with the uuid in a custom header didn't work for CORS requests, but adding the uuid as a query param should work. Downside is that the uuid will look different in the web inspector. So we restructure the RequestUuidMatcher into an abstract class, with two implementations RequestUuidInHeaderMatcher.kt (with the previous logic) and RequestUuidInQueryParamMatcher.kt with the new logic. Then the user of the library can decide which one to use.
1 parent 466577b commit 79afa72

8 files changed

Lines changed: 224 additions & 118 deletions

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,28 @@ To manually process requests:
5252
}
5353
```
5454

55+
For both cases you can choose between different strategies for how the recorded requests (including
56+
the body) and the intercepted requests are matched together. By default, only the url is used. If
57+
you want to use a different strategy, for example if you have parallel requests to the same url with
58+
different bodies (e.g. GraphQL queries), you can pass a custom `RequestMatcher` to the constructor
59+
of `RequestInspectorWebViewClient`:
60+
61+
```kotlin
62+
val webView = WebView(this)
63+
webView.webViewClient = RequestInspectorWebViewClient(
64+
webView,
65+
matcher = RequestGeneratedUuidInHeaderMatcher()
66+
)
67+
```
68+
69+
Currently available matchers are `RequestGeneratedUuidInHeaderMatcher` and
70+
`RequestGeneratedUuidInUrlMatcher`, which both create an UUID and add it to the request before it's
71+
recorded and sent. They only differ by how they attach the UUID to the request, as an additional
72+
header or as an additional query param. But both clean up the request before it's been sent.
73+
74+
If you want to implement your own matching strategy, you can implement the `RequestMatcher`
75+
interface and pass an instance of it to the constructor of `RequestInspectorWebViewClient`.
76+
5577
Known limitations
5678
===
5779

app/src/main/java/com/acsbendi/requestinspectorwebview/RequestInspectorJavaScriptInterface.kt

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,12 @@ class RequestInspectorJavaScriptInterface(webView: WebView, val matcher: Request
122122

123123
@JavascriptInterface
124124
fun getAdditionalHeaders(url: String): String {
125-
return matcher.additionalHeaders(url).toString()
125+
return matcher.getAdditionalHeaders(url).toString()
126+
}
127+
128+
@JavascriptInterface
129+
fun getAdditionalQueryParam(): String {
130+
return matcher.getAdditionalQueryParams()
126131
}
127132

128133
private fun addRecordedRequest(recordedRequest: RecordedRequest) {
@@ -251,6 +256,31 @@ function getFullUrl(url) {
251256
}
252257
}
253258
259+
function setAdditionalHeaders(url, callback) {
260+
try {
261+
var extraHeaders = JSON.parse($INTERFACE_NAME.getAdditionalHeaders(url));
262+
callback(extraHeaders);
263+
} catch (e) {
264+
console.warn('Failed to inject headers from Kotlin:', e);
265+
}
266+
}
267+
268+
function appendAdditionalQueryParams(url) {
269+
try {
270+
var extraQueryParam = $INTERFACE_NAME.getAdditionalQueryParam();
271+
if (extraQueryParam) {
272+
if (url.indexOf('?') === -1) {
273+
url += '?' + extraQueryParam;
274+
} else {
275+
url += '&' + extraQueryParam;
276+
}
277+
}
278+
} catch (e) {
279+
console.warn('Failed to inject query param from Kotlin:', e);
280+
}
281+
return url;
282+
}
283+
254284
function recordFormSubmission(form) {
255285
var jsonArr = [];
256286
for (i = 0; i < form.elements.length; i++) {
@@ -271,7 +301,7 @@ function recordFormSubmission(form) {
271301
272302
const path = form.attributes['action'] === undefined ? "/" : form.attributes['action'].nodeValue;
273303
const method = form.attributes['method'] === undefined ? "GET" : form.attributes['method'].nodeValue;
274-
const url = getFullUrl(path);
304+
const url = appendAdditionalQueryParams(getFullUrl(path));
275305
const encType = form.attributes['enctype'] === undefined ? "application/x-www-form-urlencoded" : form.attributes['enctype'].nodeValue;
276306
const err = new Error();
277307
$INTERFACE_NAME.recordFormSubmission(
@@ -303,9 +333,9 @@ let xmlhttpRequestUrl = null;
303333
XMLHttpRequest.prototype._open = XMLHttpRequest.prototype.open;
304334
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
305335
lastXmlhttpRequestPrototypeMethod = method;
306-
xmlhttpRequestUrl = url;
336+
xmlhttpRequestUrl = appendAdditionalQueryParams(url);
307337
const asyncWithDefault = async === undefined ? true : async;
308-
this._open(method, url, asyncWithDefault, user, password);
338+
this._open(method, xmlhttpRequestUrl, asyncWithDefault, user, password);
309339
};
310340
XMLHttpRequest.prototype._setRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
311341
XMLHttpRequest.prototype.setRequestHeader = function (header, value) {
@@ -315,18 +345,14 @@ XMLHttpRequest.prototype.setRequestHeader = function (header, value) {
315345
XMLHttpRequest.prototype._send = XMLHttpRequest.prototype.send;
316346
XMLHttpRequest.prototype.send = function (body) {
317347
const err = new Error();
318-
const url = getFullUrl(xmlhttpRequestUrl);
319-
// Inject headers from Kotlin if any
320-
try {
321-
var extraHeaders = JSON.parse($INTERFACE_NAME.getAdditionalHeaders(url));
348+
let url = getFullUrl(xmlhttpRequestUrl);
349+
setAdditionalHeaders(url, function(extraHeaders) {
322350
for (var h in extraHeaders) {
323351
if (extraHeaders.hasOwnProperty(h)) {
324352
this.setRequestHeader(h, extraHeaders[h]);
325353
}
326354
}
327-
} catch (e) {
328-
console.warn('Failed to inject headers from Kotlin (XHR):', e);
329-
}
355+
}.bind(this));
330356
$INTERFACE_NAME.recordXhr(
331357
url,
332358
lastXmlhttpRequestPrototypeMethod,
@@ -345,33 +371,27 @@ window.fetch = function () {
345371
const firstArgument = arguments[0];
346372
let url, method, body, headers;
347373
if (typeof firstArgument === 'string') {
348-
url = firstArgument;
374+
url = appendAdditionalQueryParams(firstArgument);
349375
if (!arguments[1]) arguments[1] = {};
350-
method = arguments[1] && 'method' in arguments[1] ? arguments[1]['method'] : "GET";
351-
body = arguments[1] && 'body' in arguments[1] ? arguments[1]['body'] : "";
352-
headers = arguments[1] && 'headers' in arguments[1] ? arguments[1]['headers'] : {};
353-
// Inject headers from Kotlin if any
354-
try {
355-
var extraHeaders = JSON.parse($INTERFACE_NAME.getAdditionalHeaders(url));
356-
arguments[1].headers = Object.assign({}, extraHeaders, headers || {});
357-
} catch (e) {
358-
console.warn('Failed to inject headers from Kotlin (fetch):', e);
359-
}
376+
method = 'method' in arguments[1] ? arguments[1]['method'] : "GET";
377+
body = 'body' in arguments[1] ? arguments[1]['body'] : "";
378+
headers = 'headers' in arguments[1] ? arguments[1]['headers'] : {};
379+
setAdditionalHeaders(url, function(extraHeaders) {
380+
arguments[1].headers = { ...extraHeaders, ...headers };
381+
});
382+
arguments[0] = url;
360383
} else {
361384
// Request object
362-
url = firstArgument.url;
385+
url = appendAdditionalQueryParams(firstArgument.url);
363386
method = firstArgument.method;
364387
body = firstArgument.body;
365388
headers = Object.fromEntries(firstArgument.headers.entries());
366-
// Inject headers from Kotlin if any
367-
try {
368-
var extraHeaders = JSON.parse($INTERFACE_NAME.getAdditionalHeaders(url));
389+
setAdditionalHeaders(url, function(extraHeaders) {
369390
for (var h in extraHeaders) {
370391
firstArgument.headers.set ? firstArgument.headers.set(h, extraHeaders[h]) : firstArgument.headers[h] = extraHeaders[h];
371392
}
372-
} catch (e) {
373-
console.warn('Failed to inject headers from Kotlin (fetch):', e);
374-
}
393+
});
394+
firstArgument.url = url;
375395
}
376396
377397
const fullUrl = getFullUrl(url);

app/src/main/java/com/acsbendi/requestinspectorwebview/RequestInspectorWebViewClient.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ import android.webkit.WebResourceRequest
77
import android.webkit.WebResourceResponse
88
import android.webkit.WebView
99
import android.webkit.WebViewClient
10-
import androidx.core.net.toUri
1110
import com.acsbendi.requestinspectorwebview.matcher.RequestMatcher
1211
import com.acsbendi.requestinspectorwebview.matcher.RequestUrlMatcher
1312

1413
@SuppressLint("SetJavaScriptEnabled")
1514
open class RequestInspectorWebViewClient @JvmOverloads constructor(
16-
webView: WebView, val matcher: RequestMatcher = RequestUrlMatcher(),
15+
webView: WebView,
16+
val matcher: RequestMatcher = RequestUrlMatcher(),
1717
private val options: RequestInspectorOptions = RequestInspectorOptions()
1818
) : WebViewClient() {
1919

@@ -48,7 +48,7 @@ open class RequestInspectorWebViewClient @JvmOverloads constructor(
4848

4949
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
5050
Log.i(LOG_TAG, "Page started loading, enabling request inspection. URL: $url")
51-
matcher.setOrigin(url)
51+
matcher.onPageStarted(url)
5252
RequestInspectorJavaScriptInterface.enabledRequestInspection(
5353
view,
5454
options.extraJavaScriptToInject
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.acsbendi.requestinspectorwebview.matcher
2+
3+
import android.util.Log
4+
import android.webkit.WebResourceRequest
5+
import androidx.core.net.toUri
6+
import com.acsbendi.requestinspectorwebview.RequestInspectorJavaScriptInterface.RecordedRequest
7+
import org.json.JSONObject
8+
import java.util.UUID
9+
10+
/**
11+
* This matcher only works for NON CORS requests. It adds a unique UUID header to each request
12+
* originating from the WebView, and matches recorded requests based on that header.
13+
*
14+
* It doesn't work for CORS requests, because it changes the headers of the request, which influences the preflight
15+
* request checking for allowed headers. Even when cleaning up the headers after the request is matched with it's body,
16+
* the CORS request will fail because the browser engine only knows about the adapted header and doesn't execute the
17+
* CORS request, because the preflight check doesn't return the custom header as allowed.
18+
*/
19+
class RequestGeneratedUuidInHeaderMatcher() : RequestGeneratedUuidMatcher() {
20+
21+
private var origin: String = ""
22+
23+
override fun getUuidFromRequest(recordedRequest: RecordedRequest): String? =
24+
recordedRequest.headers[REQUEST_INSPECTOR_ID]
25+
26+
override fun getUuidFromRequest(webResourceRequest: WebResourceRequest): String? =
27+
webResourceRequest.requestHeaders[REQUEST_INSPECTOR_ID]
28+
29+
override fun removeUuidFromRequests(request: WebResourceRequest, recordedRequest: RecordedRequest?): Pair<WebResourceRequest, RecordedRequest?> {
30+
// Clean up headers by removing REQUEST_ID_HEADER from both requests
31+
val cleanedRequest = object : WebResourceRequest by request {
32+
override fun getRequestHeaders(): Map<String, String> =
33+
request.requestHeaders.filter { (key, _) -> key != REQUEST_INSPECTOR_ID }
34+
}
35+
val cleanedRecordedRequest = recordedRequest?.copy(
36+
headers = recordedRequest.headers.filter { (key, _) -> key != REQUEST_INSPECTOR_ID }
37+
)
38+
return cleanedRequest to cleanedRecordedRequest
39+
}
40+
41+
override fun getAdditionalHeaders(url: String): JSONObject {
42+
val headersJson = JSONObject()
43+
if (getOrigin(url) == origin) {
44+
val uuid = UUID.randomUUID().toString()
45+
headersJson.put(REQUEST_INSPECTOR_ID, uuid)
46+
} else {
47+
Log.i(LOG_TAG, "Recorded CORS to $url, not adding $REQUEST_INSPECTOR_ID")
48+
}
49+
return headersJson
50+
}
51+
52+
override fun onPageStarted(url: String) {
53+
origin = getOrigin(url)
54+
}
55+
56+
private fun getOrigin(url: String): String {
57+
val uri = url.toUri()
58+
val port = if (uri.port != -1) ":${uri.port}" else ""
59+
return "${uri.scheme}://${uri.host}$port"
60+
}
61+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.acsbendi.requestinspectorwebview.matcher
2+
3+
import android.webkit.WebResourceRequest
4+
import com.acsbendi.requestinspectorwebview.RequestInspectorJavaScriptInterface
5+
import java.util.UUID
6+
7+
class RequestGeneratedUuidInQueryParamMatcher : RequestGeneratedUuidMatcher() {
8+
9+
override fun getUuidFromRequest(recordedRequest: RequestInspectorJavaScriptInterface.RecordedRequest): String? =
10+
recordedRequest.url.getQueryParameter(REQUEST_INSPECTOR_ID)
11+
12+
override fun getUuidFromRequest(webResourceRequest: WebResourceRequest): String? =
13+
webResourceRequest.url.getQueryParameter(REQUEST_INSPECTOR_ID)
14+
15+
override fun removeUuidFromRequests(
16+
request: WebResourceRequest,
17+
recordedRequest: RequestInspectorJavaScriptInterface.RecordedRequest?
18+
): Pair<WebResourceRequest, RequestInspectorJavaScriptInterface.RecordedRequest?> {
19+
val originalUrl = request.url
20+
val cleanedUrlBuilder = originalUrl.buildUpon().clearQuery()
21+
for (key in originalUrl.queryParameterNames.filter { it != REQUEST_INSPECTOR_ID }) {
22+
originalUrl.getQueryParameters(key).forEach { paramValue ->
23+
cleanedUrlBuilder.appendQueryParameter(key, paramValue)
24+
}
25+
}
26+
val cleanedUrl = cleanedUrlBuilder.build()
27+
28+
val cleanedWebResourceRequest = object : WebResourceRequest by request {
29+
override fun getUrl() = cleanedUrl
30+
}
31+
val cleanedRecordedRequest = recordedRequest?.copy(url = cleanedUrl)
32+
return cleanedWebResourceRequest to cleanedRecordedRequest
33+
}
34+
35+
override fun getAdditionalQueryParams(): String {
36+
val uuid = UUID.randomUUID().toString()
37+
return "$REQUEST_INSPECTOR_ID=$uuid"
38+
}
39+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.acsbendi.requestinspectorwebview.matcher
2+
3+
import android.webkit.WebResourceRequest
4+
import com.acsbendi.requestinspectorwebview.RequestInspectorJavaScriptInterface.RecordedRequest
5+
import com.acsbendi.requestinspectorwebview.WebViewRequest
6+
7+
abstract class RequestGeneratedUuidMatcher : RequestMatcher {
8+
9+
private val recordedRequests = mutableMapOf<String, RecordedRequest>()
10+
11+
abstract fun getUuidFromRequest(recordedRequest: RecordedRequest): String?
12+
abstract fun getUuidFromRequest(webResourceRequest: WebResourceRequest): String?
13+
abstract fun removeUuidFromRequests(
14+
request: WebResourceRequest,
15+
recordedRequest: RecordedRequest?
16+
): Pair<WebResourceRequest, RecordedRequest?>
17+
18+
final override fun addRecordedRequest(recordedRequest: RecordedRequest) {
19+
val id = getUuidFromRequest(recordedRequest) ?: return
20+
21+
synchronized(recordedRequests) {
22+
recordedRequests[id] = recordedRequest
23+
}
24+
}
25+
26+
override fun createWebViewRequest(request: WebResourceRequest): WebViewRequest {
27+
val recordedRequest = findRecordedRequest(request)
28+
val (cleanedRequest, cleanedRecordedRequest) = removeUuidFromRequests(request, recordedRequest)
29+
return WebViewRequest.create(cleanedRequest, cleanedRecordedRequest)
30+
}
31+
32+
33+
private fun findRecordedRequest(request: WebResourceRequest): RecordedRequest? {
34+
val id = getUuidFromRequest(request) ?: return null
35+
val recordedRequest = synchronized(recordedRequests) {
36+
recordedRequests.remove(id)
37+
}
38+
return recordedRequest
39+
}
40+
41+
override fun onPageStarted(url: String) {}
42+
43+
companion object {
44+
const val REQUEST_INSPECTOR_ID = "x-request-inspector-id"
45+
const val LOG_TAG = "RequestGeneratedUuidMatcher"
46+
}
47+
}

app/src/main/java/com/acsbendi/requestinspectorwebview/matcher/RequestMatcher.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import org.json.JSONObject
88
interface RequestMatcher {
99
fun addRecordedRequest(recordedRequest: RecordedRequest)
1010
fun createWebViewRequest(request: WebResourceRequest): WebViewRequest
11-
fun additionalHeaders(url: String): JSONObject = JSONObject()
12-
fun setOrigin(url: String) {}
11+
fun getAdditionalHeaders(url: String): JSONObject = JSONObject()
12+
fun getAdditionalQueryParams(): String = ""
13+
fun onPageStarted(url: String) {}
1314
}
1415

0 commit comments

Comments
 (0)