Repository: wiremock/wiremock
Pull request #3014: Expose native matcher for client ip address
Author: login: lhcopetti
Created: 2025-03-30T23:20:25Z | Merged: 2025-04-17T14:21:11Z

This PR adds a new native matcher for client IP address that is available in wiremock's request ([see here](https://github.com/wiremock/wiremock/blob/7257dd665facf7329a656d14987ccfecff33606d/src/main/java/com/github/tomakehurst/wiremock/servlet/WireMockHttpServletRequestAdapter.java#L121) for reference where the actual value is loaded from). This improvement came up during a slack discussion available below. 

Usage looks like this:

```
wm.stubFor(get(anyUrl()).withClientIp(equalTo("127.0.0.1")));
```

As a native matcher, it benefits from a improved diff output in case of errors:

![image](https://github.com/user-attachments/assets/5c54268e-03b9-4196-a923-f4391f2bca44)



This new matcher is basically syntactic sugar for:

```
wm.stubFor(get(anyUrl()).andMatching(r -> equalTo("127.0.0.1").match(r.getClientIp())));
```

And the diff output without the native matcher would look like below (no extra info in case of failure):

![image](https://github.com/user-attachments/assets/0b4797bd-5818-4de8-a970-54a9a292ac4b)


## References

Slack conversation: https://wiremock-community.slack.com/archives/C04HHJ7F20G/p1741182115686839
<!-- References to relevant GitHub issues and pull requests, esp. upstream and downstream changes -->

## Submitter checklist

- [x] Recommended: Join [WireMock Slack](https://slack.wiremock.org/) to get any help in `#help-contributing` or a project-specific channel like `#wiremock-java`
- [x] The PR request is well described and justified, including the body and the references
- [x] The PR title represents the desired changelog entry
- [x] The repository's code style is followed (see the contributing guide)
- [x] Test coverage that demonstrates that the change works as expected
- [ ] For new features, there's necessary documentation in this pull request or in a subsequent PR to [wiremock.org](https://github.com/wiremock/wiremock.org)


Diff:
diff --git a/src/main/java/com/github/tomakehurst/wiremock/client/BasicMappingBuilder.java b/src/main/java/com/github/tomakehurst/wiremock/client/BasicMappingBuilder.java
index c28caf2b7c..75202c0b1e 100644
--- a/src/main/java/com/github/tomakehurst/wiremock/client/BasicMappingBuilder.java
+++ b/src/main/java/com/github/tomakehurst/wiremock/client/BasicMappingBuilder.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2011-2024 Thomas Akehurst
+ * Copyright (C) 2011-2025 Thomas Akehurst
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -93,6 +93,12 @@ public MappingBuilder withPort(int port) {
     return this;
   }
 
+  @Override
+  public MappingBuilder withClientIp(StringValuePattern clientIpPattern) {
+    requestPatternBuilder.withClientIp(clientIpPattern);
+    return this;
+  }
+
   @Override
   public BasicMappingBuilder withHeader(String key, StringValuePattern headerPattern) {
     requestPatternBuilder.withHeader(key, headerPattern);
diff --git a/src/main/java/com/github/tomakehurst/wiremock/client/MappingBuilder.java b/src/main/java/com/github/tomakehurst/wiremock/client/MappingBuilder.java
index 8afc9af32a..2eb7651ee6 100644
--- a/src/main/java/com/github/tomakehurst/wiremock/client/MappingBuilder.java
+++ b/src/main/java/com/github/tomakehurst/wiremock/client/MappingBuilder.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2011-2024 Thomas Akehurst
+ * Copyright (C) 2011-2025 Thomas Akehurst
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -33,6 +33,8 @@ public interface MappingBuilder {
 
   MappingBuilder withPort(int port);
 
+  MappingBuilder withClientIp(StringValuePattern hostPattern);
+
   MappingBuilder atPriority(Integer priority);
 
   MappingBuilder withHeader(String key, StringValuePattern headerPattern);
diff --git a/src/main/java/com/github/tomakehurst/wiremock/matching/RequestPattern.java b/src/main/java/com/github/tomakehurst/wiremock/matching/RequestPattern.java
index 8dbdd3bb58..304e0e00e1 100644
--- a/src/main/java/com/github/tomakehurst/wiremock/matching/RequestPattern.java
+++ b/src/main/java/com/github/tomakehurst/wiremock/matching/RequestPattern.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2011-2024 Thomas Akehurst
+ * Copyright (C) 2011-2025 Thomas Akehurst
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -46,6 +46,7 @@ public class RequestPattern implements NamedValueMatcher<Request> {
   private final String scheme;
   private final StringValuePattern host;
   private final Integer port;
+  private final StringValuePattern clientIp;
   private final UrlPattern url;
   private final RequestMethod method;
   private final Map<String, MultiValuePattern> headers;
@@ -66,6 +67,7 @@ public RequestPattern(
       final String scheme,
       final StringValuePattern host,
       final Integer port,
+      final StringValuePattern clientIp,
       final UrlPattern url,
       final RequestMethod method,
       final Map<String, MultiValuePattern> headers,
@@ -81,6 +83,7 @@ public RequestPattern(
     this.scheme = scheme;
     this.host = host;
     this.port = port;
+    this.clientIp = clientIp;
     this.url = getFirstNonNull(url, UrlPattern.ANY);
     this.method = getFirstNonNull(method, RequestMethod.ANY);
     this.headers = headers;
@@ -103,6 +106,7 @@ public MatchResult match(Request request) {
             requestPartMatchResults.add(weight(schemeMatches(request), 3.0));
             requestPartMatchResults.add(weight(hostMatches(request), 10.0));
             requestPartMatchResults.add(weight(portMatches(request), 10.0));
+            requestPartMatchResults.add(weight(clientIpMatches(request), 3.0));
             requestPartMatchResults.add(
                 weight(RequestPattern.this.url.match(request.getUrl()), 10.0));
             requestPartMatchResults.add(
@@ -146,6 +150,7 @@ public RequestPattern(
       @JsonProperty("host") StringValuePattern host,
       @JsonProperty("port") Integer port,
       @JsonProperty("url") String url,
+      @JsonProperty("clientIp") StringValuePattern clientIp,
       @JsonProperty("urlPattern") String urlPattern,
       @JsonProperty("urlPath") String urlPath,
       @JsonProperty("urlPathPattern") String urlPathPattern,
@@ -165,6 +170,7 @@ public RequestPattern(
         scheme,
         host,
         port,
+        clientIp,
         UrlPattern.fromOneOf(url, urlPattern, urlPath, urlPathPattern, urlPathTemplate),
         method,
         headers,
@@ -184,6 +190,7 @@ public RequestPattern(
           null,
           null,
           null,
+          null,
           anyUrl(),
           RequestMethod.ANY,
           null,
@@ -202,6 +209,7 @@ public RequestPattern(ValueMatcher<Request> customMatcher) {
         null,
         null,
         null,
+        null,
         UrlPattern.ANY,
         RequestMethod.ANY,
         null,
@@ -221,6 +229,7 @@ public RequestPattern(CustomMatcherDefinition customMatcherDefinition) {
         null,
         null,
         null,
+        null,
         UrlPattern.ANY,
         RequestMethod.ANY,
         null,
@@ -299,6 +308,10 @@ private MatchResult portMatches(final Request request) {
     return port != null ? MatchResult.of(request.getPort() == port) : MatchResult.exactMatch();
   }
 
+  private MatchResult clientIpMatches(final Request request) {
+    return clientIp != null ? clientIp.match(request.getClientIp()) : MatchResult.exactMatch();
+  }
+
   private MatchResult allHeadersMatchResult(final Request request) {
     Map<String, MultiValuePattern> combinedHeaders = combineBasicAuthAndOtherHeaders();
 
@@ -427,6 +440,10 @@ public Integer getPort() {
     return port;
   }
 
+  public StringValuePattern getClientIp() {
+    return clientIp;
+  }
+
   public String getUrl() {
     return urlPatternOrNull(UrlPattern.class, false);
   }
diff --git a/src/main/java/com/github/tomakehurst/wiremock/matching/RequestPatternBuilder.java b/src/main/java/com/github/tomakehurst/wiremock/matching/RequestPatternBuilder.java
index 5292edc860..d49e57a4ec 100644
--- a/src/main/java/com/github/tomakehurst/wiremock/matching/RequestPatternBuilder.java
+++ b/src/main/java/com/github/tomakehurst/wiremock/matching/RequestPatternBuilder.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2016-2024 Thomas Akehurst
+ * Copyright (C) 2016-2025 Thomas Akehurst
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -33,6 +33,7 @@ public class RequestPatternBuilder {
   private String scheme;
   private StringValuePattern hostPattern;
   private Integer port;
+  private StringValuePattern clientIpPattern;
   private UrlPattern url = UrlPattern.ANY;
   private RequestMethod method = RequestMethod.ANY;
   private Map<String, MultiValuePattern> headers = new LinkedHashMap<>();
@@ -97,6 +98,7 @@ public static RequestPatternBuilder like(RequestPattern requestPattern) {
     builder.scheme = requestPattern.getScheme();
     builder.hostPattern = requestPattern.getHost();
     builder.port = requestPattern.getPort();
+    builder.clientIpPattern = requestPattern.getClientIp();
     builder.url = requestPattern.getUrlMatcher();
     builder.method = requestPattern.getMethod();
     if (requestPattern.getHeaders() != null) {
@@ -147,6 +149,11 @@ public RequestPatternBuilder withPort(int port) {
     return this;
   }
 
+  public RequestPatternBuilder withClientIp(StringValuePattern clientIpPattern) {
+    this.clientIpPattern = clientIpPattern;
+    return this;
+  }
+
   public RequestPatternBuilder withUrl(String url) {
     this.url = WireMock.urlEqualTo(url);
     return this;
@@ -270,6 +277,7 @@ public RequestPattern build() {
         scheme,
         hostPattern,
         port,
+        clientIpPattern,
         url,
         method,
         headers.isEmpty() ? null : headers,
diff --git a/src/main/java/com/github/tomakehurst/wiremock/verification/diff/Diff.java b/src/main/java/com/github/tomakehurst/wiremock/verification/diff/Diff.java
index e663e33925..46b3f67c63 100644
--- a/src/main/java/com/github/tomakehurst/wiremock/verification/diff/Diff.java
+++ b/src/main/java/com/github/tomakehurst/wiremock/verification/diff/Diff.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2016-2024 Thomas Akehurst
+ * Copyright (C) 2016-2025 Thomas Akehurst
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -106,6 +106,7 @@ public List<DiffLine<?>> getLines(Map<String, RequestMatcherExtension> customMat
 
     addHostSectionIfPresent(diffLineList);
     addPortSectionIfPresent(diffLineList);
+    addClientIpSectionIfPresent(diffLineList);
     addSchemeSectionIfPresent(diffLineList);
     addMethodSection(diffLineList);
     UrlPattern urlPattern = getFirstNonNull(requestPattern.getUrlMatcher(), anyUrl());
@@ -351,6 +352,17 @@ private void addPortSectionIfPresent(List<DiffLine<?>> diffLineList) {
     }
   }
 
+  private void addClientIpSectionIfPresent(List<DiffLine<?>> diffLineList) {
+    if (requestPattern.getClientIp() != null) {
+      StringValuePattern expectedClientIp = equalTo(String.valueOf(requestPattern.getClientIp()));
+      String actualClientIp = request.getClientIp();
+      DiffLine<String> clientIpSection =
+          new DiffLine<>(
+              "ClientIp", expectedClientIp, actualClientIp, expectedClientIp.getExpected());
+      diffLineList.addAll(toDiffDescriptionLines(clientIpSection));
+    }
+  }
+
   private void addHostSectionIfPresent(List<DiffLine<?>> diffLineList) {
     if (requestPattern.getHost() != null) {
       String hostOperator = generateOperatorString(requestPattern.getHost(), "");
diff --git a/src/main/resources/swagger/wiremock-admin-api.json b/src/main/resources/swagger/wiremock-admin-api.json
index b2b11ffab4..f220442e1a 100644
--- a/src/main/resources/swagger/wiremock-admin-api.json
+++ b/src/main/resources/swagger/wiremock-admin-api.json
@@ -2134,6 +2134,10 @@
             "maximum": 65535,
             "description": "The HTTP port number of the request URL"
           },
+          "clientIp": {
+            "type": "string",
+            "description": "The IP of the client making the request"
+          },
           "method": {
             "type": "string",
             "pattern": "^[A-Z]+$",
diff --git a/src/test/java/com/github/tomakehurst/wiremock/StubbingWithBrowserProxyAcceptanceTest.java b/src/test/java/com/github/tomakehurst/wiremock/StubbingWithBrowserProxyAcceptanceTest.java
index 8aff176dba..777459113d 100644
--- a/src/test/java/com/github/tomakehurst/wiremock/StubbingWithBrowserProxyAcceptanceTest.java
+++ b/src/test/java/com/github/tomakehurst/wiremock/StubbingWithBrowserProxyAcceptanceTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2020-2021 Thomas Akehurst
+ * Copyright (C) 2020-2025 Thomas Akehurst
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -117,6 +117,45 @@ public void doesNotMatchOnPortNumberWhenIncorrect() throws Exception {
     makeRequestAndAssertNotOk(request);
   }
 
+  @Test
+  public void matchesClientIp() throws Exception {
+    stubFor(
+        get(urlPathEqualTo("/mypath"))
+            .withClientIp(equalTo("192.168.1.1"))
+            .willReturn(ok(EXPECTED_RESPONSE_BODY)));
+
+    ClassicHttpRequest request =
+        ClassicRequestBuilder.get("http://localhost:1234/mypath")
+            .addHeader("X-Forwarded-For", "192.168.1.1")
+            .build();
+    makeRequestAndAssertOk(request);
+  }
+
+  @Test
+  public void doesNotMatchClientIpWhenItIsIncorrect() throws Exception {
+    stubFor(
+        get(urlPathEqualTo("/mypath"))
+            .withClientIp(equalTo("192.168.1.1"))
+            .willReturn(ok(EXPECTED_RESPONSE_BODY)));
+
+    ClassicHttpRequest request =
+        ClassicRequestBuilder.get("http://localhost:1234/mypath")
+            .addHeader("X-Forwarded-For", "192.168.100.1")
+            .build();
+    makeRequestAndAssertNotOk(request);
+  }
+
+  @Test
+  public void doesNotMatchClientIpWhenHeaderIsNotPresent() throws Exception {
+    stubFor(
+        get(urlPathEqualTo("/mypath"))
+            .withClientIp(equalTo("5.5.5.5"))
+            .willReturn(ok(EXPECTED_RESPONSE_BODY)));
+
+    ClassicHttpRequest request = ClassicRequestBuilder.get("http://localhost:1234/mypath").build();
+    makeRequestAndAssertNotOk(request);
+  }
+
   @Test
   public void matchesOnScheme() throws Exception {
     stubFor(
diff --git a/src/test/java/com/github/tomakehurst/wiremock/matching/RequestPatternBuilderTest.java b/src/test/java/com/github/tomakehurst/wiremock/matching/RequestPatternBuilderTest.java
index b9b723837a..23c0a18e10 100644
--- a/src/test/java/com/github/tomakehurst/wiremock/matching/RequestPatternBuilderTest.java
+++ b/src/test/java/com/github/tomakehurst/wiremock/matching/RequestPatternBuilderTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2017-2024 Thomas Akehurst
+ * Copyright (C) 2017-2025 Thomas Akehurst
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -67,6 +67,7 @@ void likeRequestPatternWithoutCustomMatcher() {
             "https",
             WireMock.equalTo("my.wiremock.org"),
             1234,
+            WireMock.equalTo("192.168.2.2"),
             WireMock.urlEqualTo("/foo"),
             RequestMethod.POST,
             Map.of("X-Header", MultiValuePattern.of(WireMock.equalTo("bar"))),
@@ -127,6 +128,7 @@ void likeRequestPatternWithoutMultipartMatcher() {
             "https",
             WireMock.equalTo("my.wiremock.org"),
             1234,
+            WireMock.equalTo("192.168.1.1"),
             WireMock.urlEqualTo("/foo"),
             RequestMethod.POST,
             Map.of("X-Header", MultiValuePattern.of(WireMock.equalTo("bar"))),
diff --git a/src/test/java/com/github/tomakehurst/wiremock/verification/diff/DiffTest.java b/src/test/java/com/github/tomakehurst/wiremock/verification/diff/DiffTest.java
index a71fc59b96..bd6bdfaeea 100644
--- a/src/test/java/com/github/tomakehurst/wiremock/verification/diff/DiffTest.java
+++ b/src/test/java/com/github/tomakehurst/wiremock/verification/diff/DiffTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2016-2024 Thomas Akehurst
+ * Copyright (C) 2016-2025 Thomas Akehurst
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -497,6 +497,21 @@ void includeHostnameIfSpecifiedWithNonEqualTo() {
                 "wrong.host\n" + "ANY\n" + "/thing\n")));
   }
 
+  @Test
+  void includeClientIpIfSpecified() {
+    Diff diff =
+        new Diff(
+            newRequestPattern(ANY, anyUrl()).withClientIp(equalTo("192.168.1.1")).build(),
+            mockRequest().clientIp("192.168.2.2").url("/thing"));
+
+    assertThat(
+        diff.toString(),
+        is(
+            junitStyleDiffMessage(
+                "equalTo 192.168.1.1\n" + "ANY\n" + "/thing\n",
+                "192.168.2.2\n" + "ANY\n" + "/thing\n")));
+  }
+
   @Test
   void includePortIfSpecified() {
     Diff diff =


Reviews:
user: login: Arc-E-Tect
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-01T13:10:06Z
user: login: RafeArnold
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T13:46:41Z
user: login: RafeArnold
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T13:48:24Z
user: login: lhcopetti
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T14:19:28Z
user: login: RafeArnold
body: could the relevant openapi (swagger) doc also be updated to document this new feature. and the wiremock.org docs in the wiremock.org repo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T15:16:11Z

Review comments:
diff_hunk: @@ -299,6 +308,10 @@ private MatchResult portMatches(final Request request) {
     return port != null ? MatchResult.of(request.getPort() == port) : MatchResult.exactMatch();
   }
 
+  private MatchResult clientIpMatches(final Request request) {
path: src/main/java/com/github/tomakehurst/wiremock/matching/RequestPattern.java
user: login: Arc-E-Tect
body: I'm a fan of documenting the not-so-obvious. In this case, I would've added Javadoc explaining the result of the call as it is not that straight forward. That said, other methods don't have similar documentation either, but I would've added it.
created_at: 2025-04-01T13:10:06Z
author_association: NONE
diff_hunk: @@ -299,6 +308,10 @@ private MatchResult portMatches(final Request request) {
     return port != null ? MatchResult.of(request.getPort() == port) : MatchResult.exactMatch();
   }
 
+  private MatchResult clientIpMatches(final Request request) {
path: src/main/java/com/github/tomakehurst/wiremock/matching/RequestPattern.java
user: login: RafeArnold
body: agreed. additional documentation never hurts.
created_at: 2025-04-01T13:48:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -299,6 +308,10 @@ private MatchResult portMatches(final Request request) {
     return port != null ? MatchResult.of(request.getPort() == port) : MatchResult.exactMatch();
   }
 
+  private MatchResult clientIpMatches(final Request request) {
path: src/main/java/com/github/tomakehurst/wiremock/matching/RequestPattern.java
user: login: lhcopetti
body: Thanks both of you for the feedback. I'll keep this in mind for my next contributions.

If I were to add a comment though, I think I would've added in the public method `MappingBuilder::withClientIp` since it is a user-facing method but I didn't find any comments around so I thought it would be better to abide by the convention.
created_at: 2025-04-02T14:19:28Z
author_association: CONTRIBUTOR

PR comments:
user: login: Arc-E-Tect
created_at: 2025-04-01T13:12:54Z
body: @lhcopetti this what I was looking for.

Looked at the code and it looks straight forward and easy enough to understand. Had one comment, but that's because sometimes I'm a bit of a puritan when it comes to documentation. 
author_association: NONE
user: login: lhcopetti
created_at: 2025-04-05T02:34:35Z
body: > could the relevant openapi (swagger) doc also be updated to document this new feature. and the wiremock.org docs in the wiremock.org repo

I added an entry for the client IP matcher in the docs and it is visible in the admin swagger-ui. Please, let me know if that works.

![image](https://github.com/user-attachments/assets/6393add1-04a7-453c-a3f7-09aba77e4bca)

author_association: CONTRIBUTOR
user: login: leeturner
created_at: 2025-04-17T12:18:18Z
body: Hi @lhcopetti could you please pull in the latest main branch so we can resolve the current test failures
author_association: CONTRIBUTOR
user: login: lhcopetti
created_at: 2025-04-17T13:26:56Z
body: Sure, @leeturner . I rebased my changes and I see now that the CI has completed successfully.
author_association: CONTRIBUTOR
user: login: leeturner
created_at: 2025-04-17T14:21:06Z
body: Awesome, thank you for your contribution 
author_association: CONTRIBUTOR