View Javadoc
1   package com.guinetik.rr.http;
2   
3   import com.fasterxml.jackson.databind.ObjectMapper;
4   import com.guinetik.rr.api.ApiException;
5   import com.guinetik.rr.request.RequestSpec;
6   import org.slf4j.Logger;
7   import org.slf4j.LoggerFactory;
8   
9   import javax.net.ssl.SSLContext;
10  import java.util.HashMap;
11  import java.util.Map;
12  import java.util.Optional;
13  import java.util.function.BiFunction;
14  import java.util.regex.Pattern;
15  
16  /**
17   * Mock implementation of {@link RocketClient} for unit testing without network requests.
18   *
19   * <p>This client simulates HTTP responses based on predefined rules, enabling unit testing
20   * of code that depends on RocketRest without requiring actual network connectivity.
21   * It supports URL pattern matching, response simulation, and invocation tracking.
22   *
23   * <h2>Features</h2>
24   * <ul>
25   *   <li>Predefined mock responses for method/URL combinations</li>
26   *   <li>Regex-based URL pattern matching</li>
27   *   <li>Simulated network latency</li>
28   *   <li>Custom HTTP status codes</li>
29   *   <li>Invocation counting for verification</li>
30   * </ul>
31   *
32   * <h2>Basic Usage</h2>
33   * <pre class="language-java"><code>
34   * MockRocketClient mockClient = new MockRocketClient();
35   *
36   * // Add mock response
37   * mockClient.addMockResponse("GET", "/users/.*", (url, body) -&gt; {
38   *     User user = new User();
39   *     user.setId(1);
40   *     user.setName("Test User");
41   *     return user;
42   * });
43   *
44   * // Execute request - returns mock response
45   * RequestSpec&lt;Void, User&gt; request = RequestBuilder.&lt;Void, User&gt;get("/users/1")
46   *     .responseType(User.class)
47   *     .build();
48   *
49   * User user = mockClient.execute(request);
50   * </code></pre>
51   *
52   * <h2>Simulating Latency and Status Codes</h2>
53   * <pre class="language-java"><code>
54   * mockClient.withLatency("/slow/.*", 1000L);  // 1 second delay
55   * mockClient.withStatusCode("/error/.*", 500); // Server error
56   * </code></pre>
57   *
58   * <h2>Verifying Calls</h2>
59   * <pre class="language-java"><code>
60   * // Check invocation count
61   * int count = mockClient.getInvocationCount("GET", "/users/.*");
62   * assertEquals(1, count);
63   *
64   * // Reset for next test
65   * mockClient.resetCounts();
66   * </code></pre>
67   *
68   * @author guinetik &lt;guinetik@gmail.com&gt;
69   * @see RocketClient
70   * @see com.guinetik.rr.RocketRestMock
71   * @since 1.0.0
72   */
73  public class MockRocketClient implements RocketClient {
74      private static final Logger logger = LoggerFactory.getLogger(MockRocketClient.class);
75  
76      /**
77       * Represents a mock response rule with matching criteria
78       */
79      private static class MockRule {
80          private final String method;
81          private final Pattern urlPattern;
82          private final BiFunction<String, Object, Object> responseProducer;
83  
84          public MockRule(String method, String urlPattern, BiFunction<String, Object, Object> responseProducer) {
85              this.method = method;
86              this.urlPattern = Pattern.compile(urlPattern);
87              this.responseProducer = responseProducer;
88          }
89  
90          public boolean matches(String method, String url) {
91              return this.method.equalsIgnoreCase(method) && urlPattern.matcher(url).matches();
92          }
93  
94          public Object produceResponse(String url, Object body) {
95              return responseProducer.apply(url, body);
96          }
97      }
98      private final ObjectMapper objectMapper = new ObjectMapper();
99      private final Map<String, String> headers = new HashMap<>();
100     private final Map<String, Integer> invocationCounts = new HashMap<>();
101     private final Map<String, Long> latencies = new HashMap<>();
102     private final Map<String, Integer> statusCodes = new HashMap<>();
103     // Store rules instead of a simple map to support pattern matching
104     private final java.util.List<MockRule> mockRules = new java.util.ArrayList<>();
105     private SSLContext sslContext;
106 
107     /**
108      * Creates a new mock client instance.
109      */
110     public MockRocketClient() {
111     }
112 
113     /**
114      * Sets a custom header value that will be included in response data
115      *
116      * @param name  Header name
117      * @param value Header value
118      * @return This MockRocketClient instance for chaining
119      */
120     public MockRocketClient withHeader(String name, String value) {
121         headers.put(name, value);
122         return this;
123     }
124 
125     /**
126      * Sets the latency for a specific endpoint in milliseconds
127      *
128      * @param urlPattern URL pattern to match
129      * @param latencyMs  Latency in milliseconds
130      * @return This MockRocketClient instance for chaining
131      */
132     public MockRocketClient withLatency(String urlPattern, long latencyMs) {
133         latencies.put(urlPattern, latencyMs);
134         return this;
135     }
136 
137     /**
138      * Sets the status code for a specific endpoint
139      *
140      * @param urlPattern URL pattern to match
141      * @param statusCode HTTP status code
142      * @return This MockRocketClient instance for chaining
143      */
144     public MockRocketClient withStatusCode(String urlPattern, int statusCode) {
145         statusCodes.put(urlPattern, statusCode);
146         return this;
147     }
148 
149     /**
150      * Adds a mock response for a specific HTTP method and URL pattern.
151      * The URL pattern is treated as a regex pattern for more flexible matching.
152      *
153      * @param method           HTTP method (GET, POST, PUT, DELETE)
154      * @param urlPattern       URL pattern to match (regex supported)
155      * @param responseProducer Function that takes (url, requestBody) and returns a response object
156      */
157     public MockRocketClient addMockResponse(String method, String urlPattern,
158                                             BiFunction<String, Object, Object> responseProducer) {
159         mockRules.add(new MockRule(method, urlPattern, responseProducer));
160         return this;
161     }
162 
163     /**
164      * Gets the number of times a specific endpoint has been invoked
165      *
166      * @param method     HTTP method
167      * @param urlPattern URL pattern
168      * @return Number of invocations
169      */
170     public int getInvocationCount(String method, String urlPattern) {
171         return invocationCounts.getOrDefault(method + ":" + urlPattern, 0);
172     }
173 
174     /**
175      * Resets all invocation counts
176      */
177     public void resetCounts() {
178         invocationCounts.clear();
179     }
180 
181     /**
182      * Finds a matching mock response rule for the given method and URL.
183      */
184     private Optional<MockRule> findMatchingRule(String method, String url) {
185         return mockRules.stream()
186                 .filter(rule -> rule.matches(method, url))
187                 .findFirst();
188     }
189 
190     @Override
191     public <Req, Res> Res execute(RequestSpec<Req, Res> requestSpec) throws RocketRestException {
192         String method = requestSpec.getMethod();
193         String url = requestSpec.getEndpoint();
194 
195         // Track invocation
196         String key = method + ":" + url;
197         invocationCounts.put(key, invocationCounts.getOrDefault(key, 0) + 1);
198 
199         // Simulate latency if configured
200         simulateLatency(url);
201 
202         // Find matching rule
203         Optional<MockRule> matchingRule = findMatchingRule(method, url);
204 
205         if (matchingRule.isPresent()) {
206             try {
207                 Object response = matchingRule.get().produceResponse(
208                         url,
209                         requestSpec.getBody()
210                 );
211 
212                 // Convert the response to the expected type
213                 if (requestSpec.getResponseType().isInstance(response)) {
214                     return (Res) response;
215                 } else if (response != null) {
216                     // Try to convert using ObjectMapper if types don't match directly
217                     return objectMapper.convertValue(response, requestSpec.getResponseType());
218                 }
219 
220                 throw new ApiException("Mock response could not be converted to required type: "
221                         + requestSpec.getResponseType().getName());
222             } catch (Exception e) {
223                 if (e instanceof ApiException) {
224                     throw (ApiException) e;
225                 }
226                 throw new ApiException("Failed to process mock response", e);
227             }
228         }
229 
230         // If we get here, no matching mock was found
231         logger.warn("No mock response found for {} : {}", method, url);
232         throw new ApiException("No mock response configured for " + method + ":" + url);
233     }
234 
235     /**
236      * Simulates network latency based on configuration
237      */
238     private void simulateLatency(String url) {
239         long latency = latencies.entrySet().stream()
240                 .filter(entry -> Pattern.compile(entry.getKey()).matcher(url).matches())
241                 .map(Map.Entry::getValue)
242                 .findFirst()
243                 .orElse(0L);
244 
245         if (latency > 0) {
246             try {
247                 Thread.sleep(latency);
248             } catch (InterruptedException e) {
249                 Thread.currentThread().interrupt();
250             }
251         }
252     }
253 
254     @Override
255     public void configureSsl(SSLContext sslContext) {
256         this.sslContext = sslContext;
257     }
258 
259     @Override
260     public void setBaseUrl(String baseUrl) {
261 
262     }
263 }