-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial version of state extension with integration tests
- Loading branch information
Showing
11 changed files
with
1,662 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,323 @@ | ||
# wiremock-extension-state | ||
# Wiremock State extension | ||
|
||
Adds support to transport state across different stubs. | ||
|
||
## Background | ||
|
||
Wiremock supports [Response Templating](https://wiremock.org/docs/response-templating/) and [Scenarios](https://wiremock.org/docs/stateful-behaviour/) | ||
to add dynamic behavior and state. Both approaches have limitations: | ||
|
||
- `Response templating` only allows accessing data submitted in the same request | ||
- `Scenarios` cannot transport any data other than the state value itself | ||
|
||
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. | ||
|
||
## Example use case | ||
|
||
Create a sandbox for a webservice. The web service has two APIs: | ||
|
||
1. `POST` to create a new identity (`POST /identity`) | ||
- Request: | ||
```json | ||
{ | ||
"firstName": "John", | ||
"lastName": "Doe" | ||
} | ||
``` | ||
- Response: | ||
```json | ||
{ | ||
"id": "kn0ixsaswzrzcfzriytrdupnjnxor1is", # Random value | ||
"firstName": "John", | ||
"lastName": "Doe" | ||
} | ||
``` | ||
2. `GET` to retrieve this value (`GET /identity/kn0ixsaswzrzcfzriytrdupnjnxor1is`) | ||
|
||
- Response: | ||
|
||
```json | ||
{ | ||
"id": "kn0ixsaswzrzcfzriytrdupnjnxor1is", | ||
"firstName": "John", | ||
"lastName": "Doe" | ||
} | ||
``` | ||
|
||
The sandbox should have no knowledge of the data that is inserted. While the `POST` can be achieved | ||
with [Response Templating](https://wiremock.org/docs/response-templating/), | ||
the `GET` won't have any knowledge of the previous post. | ||
|
||
# Usage | ||
|
||
## Register extensions | ||
|
||
Two extensions have to be registered: | ||
|
||
- `StateRecordingAction` to record any state in `postServeActions` | ||
- `ResponseTemplateTransformer` with `StateHelper` to retrieve a previously recorded state | ||
|
||
```java | ||
public class MySandbox { | ||
private final WireMockServer server; | ||
|
||
public MySandbox() { | ||
var stateRecordingAction = new StateRecordingAction(); | ||
server = new WireMockServer( | ||
options() | ||
.dynamicPort() | ||
.extensions( | ||
stateRecordingAction, | ||
new ResponseTemplateTransformer(true, "state", new StateHelper(stateRecordingAction)) | ||
) | ||
); | ||
server.start(); | ||
} | ||
} | ||
``` | ||
|
||
## Store a state | ||
|
||
The state is stored in `postServeActions` of a stub. The following parameters have to be provided: | ||
|
||
<table> | ||
<tr> | ||
<th>Parameter</th> | ||
<th>Type</th> | ||
<th>Example</th> | ||
</tr> | ||
<tr> | ||
<td> | ||
|
||
`context` | ||
|
||
</td> | ||
<td>String</td> | ||
<td> | ||
|
||
- `"context": "{{jsonPath response.body '$.id'}}"` | ||
- `"context": "{{request.pathSegments.[3]}}"` | ||
|
||
</td> | ||
</tr> | ||
<tr> | ||
<td> | ||
|
||
`state` | ||
|
||
</td> | ||
<td>Object</td> | ||
<td> | ||
|
||
```json | ||
{ | ||
"id": "{{jsonPath response.body '$.id'}}", | ||
"firstName": "{{jsonPath request.body '$.firstName'}}", | ||
"lastName": "{{jsonPath request.body '$.lastName'}}" | ||
} | ||
``` | ||
|
||
</td> | ||
</tr> | ||
</table> | ||
|
||
Templating (as in [Response Templating](https://wiremock.org/docs/response-templating/)) is supported for these. The following models are exposed: | ||
|
||
- `request`: All model elements of as in [Response Templating](https://wiremock.org/docs/response-templating/) | ||
- `response`: `body` and `headers` | ||
|
||
Full example: | ||
|
||
```json | ||
{ | ||
"request": {}, | ||
"response": {}, | ||
"postServeActions": [ | ||
{ | ||
"name": "recordState", | ||
"parameters": { | ||
"context": "{{jsonPath response.body '$.id'}}", | ||
"state": { | ||
"id": "{{jsonPath response.body '$.id'}}", | ||
"firstName": "{{jsonPath request.body '$.firstName'}}", | ||
"lastName": "{{jsonPath request.body '$.lastName'}}" | ||
} | ||
} | ||
} | ||
] | ||
} | ||
|
||
``` | ||
|
||
### state expiration | ||
|
||
This extension uses [caffeine](https://github.com/ben-manes/caffeine) to store the current state and to achieve an expiration (to avoid memory leaks). | ||
The default expiration is 60 minutes. The default value can be overwritten (`0` = default = 60 minutes): | ||
|
||
```java | ||
int expiration=1024; | ||
var stateRecordingAction=new StateRecordingAction(expiration); | ||
``` | ||
|
||
## Retrieve a state | ||
|
||
A state can be retrieved using a handlebar helper. In the example above, the `StateHelper` is registered by the name `state`. | ||
In a `jsonBody`, the state can be retrieved via: `"clientId": "{{state context=request.pathSegments.[1] property='firstname'}}",` | ||
|
||
The handler has two parameters: | ||
|
||
- `context`: has to match the context data was registered with | ||
- `property`: the property of the state context to retrieve, so e.g. `firstName` | ||
|
||
### Error handling | ||
|
||
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 | ||
exception. | ||
|
||
Example response with error: | ||
|
||
```json | ||
{ | ||
"id": "kn0ixsaswzrzcfzriytrdupnjnxor1is", | ||
"firstName": "[ERROR: No state for context kn0ixsaswzrzcfzriytrdupnjnxor1is, property firstName found]", | ||
"lastName": "Doe" | ||
} | ||
``` | ||
|
||
# Example | ||
|
||
## Java | ||
|
||
```java | ||
class StateTest { | ||
@RegisterExtension | ||
public static WireMockExtension wm = WireMockExtension.newInstance() | ||
.options( | ||
wireMockConfig().dynamicPort().dynamicHttpsPort() | ||
.extensions( | ||
stateRecordingAction, | ||
new ResponseTemplateTransformer(true, "state", new StateHelper(stateRecordingAction)) | ||
) | ||
) | ||
.build(); | ||
|
||
|
||
private void createPostStub() throws JsonProcessingException { | ||
wm.stubFor( | ||
post(urlEqualTo(TEST_URL)) | ||
.willReturn( | ||
WireMock.ok() | ||
.withHeader("content-type", "application/json") | ||
.withJsonBody( | ||
mapper.readTree( | ||
mapper.writeValueAsString(Map.of("id", "{{randomValue length=32 type='ALPHANUMERIC' uppercase=false}}"))) | ||
) | ||
) | ||
.withPostServeAction( | ||
"recordState", | ||
Parameters.from( | ||
Map.of( | ||
"context", "{{jsonPath response.body '$.id'}}", | ||
"state", Map.of( | ||
"id", "{{jsonPath response.body '$.id'}}", | ||
"firstName", "{{jsonPath request.body '$.contextValue'}}", | ||
"lastName", "{{jsonPath request.body '$.contextValue'}}" | ||
) | ||
) | ||
) | ||
) | ||
); | ||
} | ||
|
||
private void createGetStub() throws JsonProcessingException { | ||
wm.stubFor( | ||
get(urlPathMatching(TEST_URL + "/[^/]+")) | ||
.willReturn( | ||
WireMock.ok() | ||
.withHeader("content-type", "application/json") | ||
.withJsonBody( | ||
mapper.readTree( | ||
mapper.writeValueAsString(Map.of( | ||
"id", "{{state context=request.pathSegments.[1] property='id'}}"), | ||
"firstName", "{{state context=request.pathSegments.[1] property='firstName'}}"), | ||
"lastName", "{{state context=request.pathSegments.[1] property='lastName'}}") | ||
) | ||
) | ||
); | ||
} | ||
|
||
} | ||
``` | ||
|
||
## JSON | ||
|
||
### `POST` | ||
|
||
```json | ||
{ | ||
"request": { | ||
"method": "POST", | ||
"url": "/test", | ||
"headers": { | ||
"content-type": { | ||
"contains": "json" | ||
}, | ||
"accept": { | ||
"contains": "json" | ||
} | ||
} | ||
}, | ||
"response": { | ||
"status": 200, | ||
"headers": { | ||
"Content-Type": "application/json" | ||
}, | ||
"jsonBody": { | ||
"id": "{{randomValue length=32 type='ALPHANUMERIC' uppercase=false}}", | ||
"firstName": "{{jsonPath request.body '$.firstName'}}", | ||
"lastName": "{{jsonPath request.body '$.lastName'}}" | ||
} | ||
}, | ||
"postServeActions": [ | ||
{ | ||
"name": "recordState", | ||
"parameters": { | ||
"context": "{{jsonPath response.body '$.id'}}", | ||
"state": { | ||
"id": "{{jsonPath response.body '$.id'}}", | ||
"firstName": "{{jsonPath request.body '$.firstName'}}", | ||
"lastName": "{{jsonPath response.body '$.lastName'}}" | ||
} | ||
} | ||
} | ||
] | ||
} | ||
``` | ||
|
||
### `GET` | ||
|
||
```json | ||
{ | ||
"request": { | ||
"method": "GET", | ||
"urlPattern": "/test/[^\/]+", | ||
"headers": { | ||
"accept": { | ||
"contains": "json" | ||
} | ||
} | ||
}, | ||
"response": { | ||
"status": 200, | ||
"headers": { | ||
"Content-Type": "application/json" | ||
}, | ||
"jsonBody": { | ||
"id": "{{state context=request.pathSegments.[1] property='id'}}", | ||
"firstName": "{{state context=request.pathSegments.[1] property='firstName'}}", | ||
"lastName": "{{state context=request.pathSegments.[1] property='lastName'}}" | ||
} | ||
} | ||
} | ||
``` | ||
|
Oops, something went wrong.