Skip to content

Commit 5fe5aa9

Browse files
committed
Initial version of state extension with integration tests
1 parent 504b2ec commit 5fe5aa9

File tree

11 files changed

+1662
-1
lines changed

11 files changed

+1662
-1
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,8 @@
2222
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
2323
hs_err_pid*
2424
replay_pid*
25+
26+
.idea
27+
28+
build/
29+
.gradle/

README.md

Lines changed: 323 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,323 @@
1-
# wiremock-extension-state
1+
# Wiremock State extension
2+
3+
Adds support to transport state across different stubs.
4+
5+
## Background
6+
7+
Wiremock supports [Response Templating](https://wiremock.org/docs/response-templating/) and [Scenarios](https://wiremock.org/docs/stateful-behaviour/)
8+
to add dynamic behavior and state. Both approaches have limitations:
9+
10+
- `Response templating` only allows accessing data submitted in the same request
11+
- `Scenarios` cannot transport any data other than the state value itself
12+
13+
In order to mock more complex scenarios which are similar to a sandbox for a web service, it can be required to use parts of a previous request.
14+
15+
## Example use case
16+
17+
Create a sandbox for a webservice. The web service has two APIs:
18+
19+
1. `POST` to create a new identity (`POST /identity`)
20+
- Request:
21+
```json
22+
{
23+
"firstName": "John",
24+
"lastName": "Doe"
25+
}
26+
```
27+
- Response:
28+
```json
29+
{
30+
"id": "kn0ixsaswzrzcfzriytrdupnjnxor1is", # Random value
31+
"firstName": "John",
32+
"lastName": "Doe"
33+
}
34+
```
35+
2. `GET` to retrieve this value (`GET /identity/kn0ixsaswzrzcfzriytrdupnjnxor1is`)
36+
37+
- Response:
38+
39+
```json
40+
{
41+
"id": "kn0ixsaswzrzcfzriytrdupnjnxor1is",
42+
"firstName": "John",
43+
"lastName": "Doe"
44+
}
45+
```
46+
47+
The sandbox should have no knowledge of the data that is inserted. While the `POST` can be achieved
48+
with [Response Templating](https://wiremock.org/docs/response-templating/),
49+
the `GET` won't have any knowledge of the previous post.
50+
51+
# Usage
52+
53+
## Register extensions
54+
55+
Two extensions have to be registered:
56+
57+
- `StateRecordingAction` to record any state in `postServeActions`
58+
- `ResponseTemplateTransformer` with `StateHelper` to retrieve a previously recorded state
59+
60+
```java
61+
public class MySandbox {
62+
private final WireMockServer server;
63+
64+
public MySandbox() {
65+
var stateRecordingAction = new StateRecordingAction();
66+
server = new WireMockServer(
67+
options()
68+
.dynamicPort()
69+
.extensions(
70+
stateRecordingAction,
71+
new ResponseTemplateTransformer(true, "state", new StateHelper(stateRecordingAction))
72+
)
73+
);
74+
server.start();
75+
}
76+
}
77+
```
78+
79+
## Store a state
80+
81+
The state is stored in `postServeActions` of a stub. The following parameters have to be provided:
82+
83+
<table>
84+
<tr>
85+
<th>Parameter</th>
86+
<th>Type</th>
87+
<th>Example</th>
88+
</tr>
89+
<tr>
90+
<td>
91+
92+
`context`
93+
94+
</td>
95+
<td>String</td>
96+
<td>
97+
98+
- `"context": "{{jsonPath response.body '$.id'}}"`
99+
- `"context": "{{request.pathSegments.[3]}}"`
100+
101+
</td>
102+
</tr>
103+
<tr>
104+
<td>
105+
106+
`state`
107+
108+
</td>
109+
<td>Object</td>
110+
<td>
111+
112+
```json
113+
{
114+
"id": "{{jsonPath response.body '$.id'}}",
115+
"firstName": "{{jsonPath request.body '$.firstName'}}",
116+
"lastName": "{{jsonPath request.body '$.lastName'}}"
117+
}
118+
```
119+
120+
</td>
121+
</tr>
122+
</table>
123+
124+
Templating (as in [Response Templating](https://wiremock.org/docs/response-templating/)) is supported for these. The following models are exposed:
125+
126+
- `request`: All model elements of as in [Response Templating](https://wiremock.org/docs/response-templating/)
127+
- `response`: `body` and `headers`
128+
129+
Full example:
130+
131+
```json
132+
{
133+
"request": {},
134+
"response": {},
135+
"postServeActions": [
136+
{
137+
"name": "recordState",
138+
"parameters": {
139+
"context": "{{jsonPath response.body '$.id'}}",
140+
"state": {
141+
"id": "{{jsonPath response.body '$.id'}}",
142+
"firstName": "{{jsonPath request.body '$.firstName'}}",
143+
"lastName": "{{jsonPath request.body '$.lastName'}}"
144+
}
145+
}
146+
}
147+
]
148+
}
149+
150+
```
151+
152+
### state expiration
153+
154+
This extension uses [caffeine](https://github.com/ben-manes/caffeine) to store the current state and to achieve an expiration (to avoid memory leaks).
155+
The default expiration is 60 minutes. The default value can be overwritten (`0` = default = 60 minutes):
156+
157+
```java
158+
int expiration=1024;
159+
var stateRecordingAction=new StateRecordingAction(expiration);
160+
```
161+
162+
## Retrieve a state
163+
164+
A state can be retrieved using a handlebar helper. In the example above, the `StateHelper` is registered by the name `state`.
165+
In a `jsonBody`, the state can be retrieved via: `"clientId": "{{state context=request.pathSegments.[1] property='firstname'}}",`
166+
167+
The handler has two parameters:
168+
169+
- `context`: has to match the context data was registered with
170+
- `property`: the property of the state context to retrieve, so e.g. `firstName`
171+
172+
### Error handling
173+
174+
Missing Helper properties as well as unknown context properties are reported as error. Wiremock renders them in the field, itself, so there won't be an
175+
exception.
176+
177+
Example response with error:
178+
179+
```json
180+
{
181+
"id": "kn0ixsaswzrzcfzriytrdupnjnxor1is",
182+
"firstName": "[ERROR: No state for context kn0ixsaswzrzcfzriytrdupnjnxor1is, property firstName found]",
183+
"lastName": "Doe"
184+
}
185+
```
186+
187+
# Example
188+
189+
## Java
190+
191+
```java
192+
class StateTest {
193+
@RegisterExtension
194+
public static WireMockExtension wm = WireMockExtension.newInstance()
195+
.options(
196+
wireMockConfig().dynamicPort().dynamicHttpsPort()
197+
.extensions(
198+
stateRecordingAction,
199+
new ResponseTemplateTransformer(true, "state", new StateHelper(stateRecordingAction))
200+
)
201+
)
202+
.build();
203+
204+
205+
private void createPostStub() throws JsonProcessingException {
206+
wm.stubFor(
207+
post(urlEqualTo(TEST_URL))
208+
.willReturn(
209+
WireMock.ok()
210+
.withHeader("content-type", "application/json")
211+
.withJsonBody(
212+
mapper.readTree(
213+
mapper.writeValueAsString(Map.of("id", "{{randomValue length=32 type='ALPHANUMERIC' uppercase=false}}")))
214+
)
215+
)
216+
.withPostServeAction(
217+
"recordState",
218+
Parameters.from(
219+
Map.of(
220+
"context", "{{jsonPath response.body '$.id'}}",
221+
"state", Map.of(
222+
"id", "{{jsonPath response.body '$.id'}}",
223+
"firstName", "{{jsonPath request.body '$.contextValue'}}",
224+
"lastName", "{{jsonPath request.body '$.contextValue'}}"
225+
)
226+
)
227+
)
228+
)
229+
);
230+
}
231+
232+
private void createGetStub() throws JsonProcessingException {
233+
wm.stubFor(
234+
get(urlPathMatching(TEST_URL + "/[^/]+"))
235+
.willReturn(
236+
WireMock.ok()
237+
.withHeader("content-type", "application/json")
238+
.withJsonBody(
239+
mapper.readTree(
240+
mapper.writeValueAsString(Map.of(
241+
"id", "{{state context=request.pathSegments.[1] property='id'}}"),
242+
"firstName", "{{state context=request.pathSegments.[1] property='firstName'}}"),
243+
"lastName", "{{state context=request.pathSegments.[1] property='lastName'}}")
244+
)
245+
)
246+
);
247+
}
248+
249+
}
250+
```
251+
252+
## JSON
253+
254+
### `POST`
255+
256+
```json
257+
{
258+
"request": {
259+
"method": "POST",
260+
"url": "/test",
261+
"headers": {
262+
"content-type": {
263+
"contains": "json"
264+
},
265+
"accept": {
266+
"contains": "json"
267+
}
268+
}
269+
},
270+
"response": {
271+
"status": 200,
272+
"headers": {
273+
"Content-Type": "application/json"
274+
},
275+
"jsonBody": {
276+
"id": "{{randomValue length=32 type='ALPHANUMERIC' uppercase=false}}",
277+
"firstName": "{{jsonPath request.body '$.firstName'}}",
278+
"lastName": "{{jsonPath request.body '$.lastName'}}"
279+
}
280+
},
281+
"postServeActions": [
282+
{
283+
"name": "recordState",
284+
"parameters": {
285+
"context": "{{jsonPath response.body '$.id'}}",
286+
"state": {
287+
"id": "{{jsonPath response.body '$.id'}}",
288+
"firstName": "{{jsonPath request.body '$.firstName'}}",
289+
"lastName": "{{jsonPath response.body '$.lastName'}}"
290+
}
291+
}
292+
}
293+
]
294+
}
295+
```
296+
297+
### `GET`
298+
299+
```json
300+
{
301+
"request": {
302+
"method": "GET",
303+
"urlPattern": "/test/[^\/]+",
304+
"headers": {
305+
"accept": {
306+
"contains": "json"
307+
}
308+
}
309+
},
310+
"response": {
311+
"status": 200,
312+
"headers": {
313+
"Content-Type": "application/json"
314+
},
315+
"jsonBody": {
316+
"id": "{{state context=request.pathSegments.[1] property='id'}}",
317+
"firstName": "{{state context=request.pathSegments.[1] property='firstName'}}",
318+
"lastName": "{{state context=request.pathSegments.[1] property='lastName'}}"
319+
}
320+
}
321+
}
322+
```
323+

0 commit comments

Comments
 (0)