Skip to content

Commit

Permalink
Initial version of state extension with integration tests
Browse files Browse the repository at this point in the history
  • Loading branch information
dirkbolte committed Jun 20, 2023
1 parent 504b2ec commit 5fe5aa9
Show file tree
Hide file tree
Showing 11 changed files with 1,662 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,8 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*

.idea

build/
.gradle/
324 changes: 323 additions & 1 deletion README.md
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'}}"
}
}
}
```

Loading

0 comments on commit 5fe5aa9

Please sign in to comment.