wiremock / wiremock-state-extension

Adds support for transporting state across different API mock stubs
Apache License 2.0
16 stars 5 forks source link
hacktoberfest wiremock wiremock-extension

WireMock State extension

GitHub release (latest by date) Maven Central Slack GitHub contributors Line Coverage Branches Coverage

WireMock Logo

Adds support to transport state across different stubs.

Feature summary

Glossary

Term Description
context States are scoped by a context. Behavior is similar to a key in a map.
state The actual state. There can be only one per context - but it can be overwritten.
property A property of a state. A state can have multiple properties.
list Next to the singularic state, a context can have a list of states. The list of states can be modified but states within the list can't.
classDiagram
    direction LR
    Store "1" *-- "*" Context
    Context "1" *-- "1" State
    Context "1" *-- "1" List
    List "1" *-- "*" State
    State "1" *-- "*" Property
    class Property {
        +String key
        +String value
    }

Background

WireMock supports Response Templating and Scenarios to add dynamic behavior and state. Both approaches have limitations:

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 cases

Create a sandbox for a webservice. The web service has two APIs:

CRUD

  1. POST to create a new identity (POST /identity)
    • Request:
      {
      "firstName": "John",
      "lastName": "Doe"
      }
    • Response:
      {
      "id": "kn0ixsaswzrzcfzriytrdupnjnxor1is", # Random value
      "firstName": "John",
      "lastName": "Doe" 
      }
  2. GET to retrieve this value (GET /identity/kn0ixsaswzrzcfzriytrdupnjnxor1is)

The sandbox should have no knowledge of the data that is inserted. While the POST can be achieved with Response Templating, the GET won't have any knowledge of the previous post.

Queue

  1. POST add a new item (POST /queue)
    • Request:
      {
      "firstName": "John",
      "lastName": "Doe"
      }
    • Response:
      {
      "id": "kn0ixsaswzrzcfzriytrdupnjnxor1is", # Random value
      "firstName": "John",
      "lastName": "Doe" 
      }

2POST add another new item (POST /queue)

  1. GET to retrieve the first value (GET /queue)
  1. GET to retrieve the second value (GET /queue)

Usage

Compatibility matrix

wiremock-state-extension version WireMock version
0.8.0+ 3.7.0+
0.7.0+ 3.6.0+
0.5.1+ 3.3.1+
0.1.0+ 3.0.0+
0.0.6+ 3.0.0-beta-14+
0.0.3+ 3.0.0-beta-11+

Installation

Gradle

dependencies {
    testImplementation("org.wiremock.extensions:wiremock-state-extension:<your-version>")
}

Maven

<dependencies>
  <dependency>
    <groupId>org.wiremock.extensions</groupId>
    <artifactId>wiremock-state-extension</artifactId>
    <version>your-version</version>
    <scope>test</scope>
  </dependency>
</dependencies>

GitHub Packages

You can also install the dependencies from GitHub Packages. Follow the instructions on GitHub Docs to add authentication to GitHub packages.

Use GitHub Packages in Gradle ```groovy repositories { maven { url = uri("https://maven.pkg.github.com/wiremock/wiremock-extension-state") } } dependencies { testImplementation("org.wiremock.extensions:wiremock-state-extension:") } ```
Use GitHub Packages in Maven ```xml github-wiremock-state-extension WireMock Extension State Apache Maven Packages https://maven.pkg.github.com/wiremock/wiremock-state-extension org.wiremock.extensions wiremock-state-extension your-version test ```

Register extension

Java

This extension makes use of WireMock's ExtensionFactory, so only one extension has to be registered: StateExtension. In order to use them, templating has to be enabled as well. A store for all state data has to be provided. This extension provides a CaffeineStore which can be used - or you can provide your own store:

public class MySandbox {
    private final WireMockServer server;

    public MySandbox() {
        var stateRecordingAction = new StateRecordingAction();
        var store = new CaffeineStore();
        server = new WireMockServer(
            options()
                .dynamicPort()
                .templatingEnabled(true)
                .globalTemplating(true)
                .extensions(new StateExtension(store))
        );
        server.start();
    }
}

Standalone

This extension uses the ServiceLoader extension to be loaded by WireMock. As Standalone version, it will use CaffeineStore for storing any data.

The standalone jar can be downloaded from GitHub .

java -cp "wiremock-state-extension-standalone-0.4.0.jar:wiremock-standalone-3.3.0.jar" wiremock.Run

Docker

Using the extension with docker is similar to its usage with usage standalone: it just has to be available on the classpath to be loaded automatically - it does not have to be added via --extensions .

docker run -it --rm \
-p 8080:8080 \
--name wiremock \
-v $PWD/extensions:/var/wiremock/extensions \
wiremock/wiremock  \
-- --global-response-templating

Record a state

The state is recorded in serveEventListeners of a stub. The following functionalities are provided:

state and list can be used in the same ServeEventListener (would count as ONE updates). Adding multiple recordState ServeEventListener is supported.

The following parameters have to be provided:

Parameter Type Example
`context` String - `"context": "{{jsonPath response.body '$.id'}}"` - `"context": "{{request.pathSegments.[3]}}"`
`state` Object ```json { "id": "{{jsonPath response.body '$.id'}}", "firstName": "{{jsonPath request.body '$.firstName'}}", "lastName": "{{jsonPath request.body '$.lastName'}}" } ```
`list` Dictionary - `addLast` : Adds the object to the end of the list - `addFirst` : Adds the object to the front of the list ```json { "addLast": { "id": "{{jsonPath response.body '$.id'}}", "firstName": "{{jsonPath request.body '$.firstName'}}", "lastName": "{{jsonPath request.body '$.lastName'}}" } } ```

Templating (as in Response Templating) is supported for these. The following models are exposed:

Full example for storing a state:

{
  "request": {},
  "response": {},
  "serveEventListeners": [
    {
      "name": "recordState",
      "parameters": {
        "context": "{{jsonPath response.body '$.id'}}",
        "state": {
          "id": "{{jsonPath response.body '$.id'}}",
          "firstName": "{{jsonPath request.body '$.firstName'}}",
          "lastName": "{{jsonPath request.body '$.lastName'}}"
        }
      }
    }
  ]
}

To record a complete response body, use (ATTENTION: tripple {{{):

{
  "request": {},
  "response": {},
  "serveEventListeners": [
    {
      "name": "recordState",
      "parameters": {
        "context": "{{jsonPath response.body '$.id'}}",
        "state": {
          "fullBody": "{{{jsonPath response.body '$'}}}"
        }
      }
    }
  ]
}

To delete a selective property, ensure that the field has the value null as string, e.g. by specifying default='null for jsonpath:

{
  "request": {},
  "response": {},
  "serveEventListeners": [
    {
      "name": "recordState",
      "parameters": {
        "context": "{{jsonPath response.body '$.id'}}",
        "state": {
          "id": "{{jsonPath response.body '$.id'}}",
          "firstName": "{{jsonPath request.body '$.firstName' default='null'}}",
          "lastName": "{{jsonPath request.body '$.lastName' default='null'}}"
        }
      }
    }
  ]
}

To append a state to a list:

{
  "request": {},
  "response": {},
  "serveEventListeners": [
    {
      "name": "recordState",
      "parameters": {
        "context": "{{jsonPath response.body '$.id'}}",
        "list": {
          "addLast": {
            "id": "{{jsonPath response.body '$.id'}}",
            "firstName": "{{jsonPath request.body '$.firstName'}}",
            "lastName": "{{jsonPath request.body '$.lastName'}}"
          }
        }
      }
    }
  ]
}

Accessing the previous state

You can use the state helper to temporarily access the previous state. Use the state helper in the same way as you would use it when you retrieve a state.

Note: This extension does not keep a history in itself but it's an effect of the evaluation order. As templates are evaluated before the state is written, the state you access in recordState is the one before you store the new one (so there might be none - you might want to use default for these cases). In case you have multiple recordState serveEventListeners, you will have new states being created in between, thus the previous state is the last stored one (so: not the one before the request).

  1. listener 1 is executed
    1. accesses state n
    2. stores state n+1
  2. listener 2 is executed
    1. accesses state n+1
    2. stores state n+2

The evaluation order of listeners within a stub as well as across stubs is not guaranteed.

{
  "request": {},
  "response": {},
  "serveEventListeners": [
    {
      "name": "recordState",
      "parameters": {
        "context": "{{jsonPath response.body '$.id'}}",
        "state": {
          "id": "{{jsonPath response.body '$.id'}}",
          "firstName": "{{jsonPath request.body '$.firstName'}}",
          "lastName": "{{jsonPath request.body '$.lastName'}}",
          "birthName": "{{state context='$.id' property='lastName' default=''}}"
        }
      }
    }
  ]
}

Deleting a state

Similar to recording a state, its deletion can be initiated in serveEventListeners of a stub.

The following parameters have to be provided:

Task Parameter Type Example
context deletion `context`
Deletes a single context.
String - `"context": "{{jsonPath response.body '$.id'}}"` - `"context": "{{request.pathSegments.[3]}}"`
`contexts` Deletes all contexts specified in the array. Array
An empty array or unknown contexts are silently ignored.
- `"contexts": ["{{jsonPath response.body '$.firstContext'}}", "{{jsonPath response.body '$.secondContext'}}"]` - `"contexts": ["a", "b", "c"]`
`contextsMatching` Deletes all contexts matching the regex. String (regex)
An invalid regex results in an exception. If there are no matches, this is silently ignored.
- `"contextsMatching": ".*userNa.*"` - `"contextsMatching": ".*(john|jane).*"` - `"contextsMatching": ".*"` (delete all contexts)
List entry deletion - `context` (string): the context to delete the list entry from - `list` (dictionary, see next column) If `list` is specified and `context` is missing, an error is thrown. Dictionary - only one option is interpreted (top to bottom as listed here) - `deleteFirst` (Boolean) - deletes first element in the list - `deleteLast` (Boolean) - deletes last element in the list - `deleteIndex` (Number as String) - deletes element at index (starting with `0` - last element = `-1`). Number has to be represented as String. Supports templating. - `deleteWhere` (Object with `property` and `value`) - Deletes first element matching the condition. Both `property` and `value` support templating. - ```json { "name": "deleteState", "parameters": { "list": { "deleteFirst": true } } } ``` - ```json { "name": "deleteState", "parameters": { "list": { "deleteLast": true } } } ``` - ```json { "name": "deleteState", "parameters": { "list": { "deleteIndex": "1" } } } ``` - ```json { "name": "deleteState", "parameters": { "list": { "deleteIndex": "-1" } } } ``` - ```json { "name": "deleteState", "parameters": { "list": { "deleteIndex": "{{request.pathSegments.[1]}}" } } } ``` - ```json { "name": "deleteState", "parameters": { "list": { "deleteWhere": { "property": "myProperty", "value": "{{request.pathSegments.[2]}}" } } } } ```

Templating (as in Response Templating) is supported for these. The following models are exposed:

Full example:

{
  "request": {},
  "response": {},
  "serveEventListeners": [
    {
      "name": "deleteState",
      "parameters": {
        "context": "{{jsonPath response.body '$.id'}}"
      }
    }
  ]
}

state expiration

This extension provides a CaffeineStore which uses 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):

int expiration = 1024;
var store = new CaffeineStore(expiration);

Match a request against a context

To have a WireMock stub only apply when there's actually a matching context, you can use the StateRequestMatcher . This helps to model different behavior for requests with and without a matching context. The parameter supports templates.

Positive context exists match

{
  "request": {
    "method": "GET",
    "urlPattern": "/test/[^\/]+",
    "customMatcher": {
      "name": "state-matcher",
      "parameters": {
        "hasContext": "{{request.pathSegments.[1]}}"
      }
    }
  },
  "response": {
    "status": 200
  }
}

Property existence match

In addition to the existence of a context, you can check for the existence or absence of a property within that context. The following matchers are available:

As for other matchers, templating is supported.

{
  "request": {
    "method": "GET",
    "urlPattern": "/test/[^\/]+/[^\/]+",
    "customMatcher": {
      "name": "state-matcher",
      "parameters": {
        "hasContext": "{{request.pathSegments.[1]}}",
        "hasProperty": "{{request.pathSegments.[2]}}"
      }
    }
  },
  "response": {
    "status": 200
  }
}

Full flexible property match

In case you want full flexibility into matching on a property, you can simply specify property and use one of WireMock's built-in matchers, allowing you to configure logical operators, regex, date matchers, absence and much more. The basic syntax:

"property": {
<property-a>: <matcher-a>,
<property-b>: <matcher-b>
}

Example:

{
  "request": {
    "method": "GET",
    "urlPattern": "/test/[^\/]+/[^\/]+",
    "customMatcher": {
      "name": "state-matcher",
      "parameters": {
        "property": {
          "myProperty": {
            "contains": "myValue"
          }
        }
      }
    },
    "response": {
      "status": 200
    }
  }

The implementation makes use of WireMock's internal matching system and supports any implementation of StringValuePattern. As of WireMock 3.3, this includes equalTo,equalToJson,matchesJsonPath,matchesJsonSchema,equalToXml,matchesXPath,contains,not,doesNotContain,matches,doesNotMatch,before, after,equalToDateTime,anything,absent,and,or,matchesPathTemplate. For documentation on using these matchers, check the WireMock documentation

Context update count match

Whenever a request with a serve event listener recordState or deleteState is processed, the internal context update counter is increased. The update count is increased by one whenever there is at least one change to a context (so: property adding/change, list entry addition/deletion). Multiple event listeners with multiple changes of a single context within a single request only result in an increase by one. for request matching as well. The following matchers are available:

As for other matchers, templating is supported. In case the provided value for this check is not numeric, it is handled as non-matching. No error will be reported or logged.

{
  "request": {
    "method": "GET",
    "urlPattern": "/test/[^\/]+",
    "customMatcher": {
      "name": "state-matcher",
      "parameters": {
        "hasContext": "{{request.pathSegments.[1]}}",
        "updateCountEqualTo": "1"
      }
    }
  },
  "response": {
    "status": 200
  }
}

List size match

The list size (which is modified via recordState or deleteState) can be used for request matching as well. The following matchers are available:

As for other matchers, templating is supported. In case the provided value for this check is not numeric, it is handled as non-matching. No error will be reported or logged.

{
  "request": {
    "method": "GET",
    "urlPattern": "/test/[^\/]+",
    "customMatcher": {
      "name": "state-matcher",
      "parameters": {
        "hasContext": "{{request.pathSegments.[1]}}",
        "listSizeEqualTo": "1"
      }
    }
  },
  "response": {
    "status": 200
  }
}

Full flexible list entry property match

Similar to properties, you have full flexibility into matching on a property of a list entry by specifying list and using one of WireMock's built-in matchers The basic syntax:

"list": {
  <index-a>: {
    <property-a>: <matcher-a>,
    <property-b>: <matcher-b>
  },
  <index-b>: {
    <property-a>: <matcher-a>,
    <property-b>: <matcher-b>
  }
}

As index, you can use the actual index as well as first, last, -1.

Example:

{
  "request": {
    "method": "GET",
    "urlPattern": "/test/[^\/]+/[^\/]+",
    "customMatcher": {
      "name": "state-matcher",
      "parameters": {
        "list": {
          "1": {
            "myProperty": {
              "contains": "myValue"
            }
          }
        }
      }
    },
    "response": {
      "status": 200
    }
  }

The implementation makes use of WireMock's internal matching system and supports any implementation of StringValuePattern. As of WireMock 3.3, this includes equalTo,equalToJson,matchesJsonPath,matchesJsonSchema,equalToXml,matchesXPath,contains,not,doesNotContain,matches,doesNotMatch,before, after,equalToDateTime,anything,absent,and,or,matchesPathTemplate. For documentation on using these matchers, check the WireMock documentation

Negative context exists match

{
  "request": {
    "method": "GET",
    "urlPattern": "/test/[^\/]+",
    "customMatcher": {
      "name": "state-matcher",
      "parameters": {
        "hasNotContext": "{{request.pathSegments.[1]}}"
      }
    }
  },
  "response": {
    "status": 400
  }
}

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 the following parameters:

You have to choose either property or list (otherwise, you will get a configuration error).

To retrieve a full body, use tripple braces: {{{state context=request.pathSegments.[1] property='fullBody'}}} .

When registering this extension, this helper is available via WireMock's response templating as well as in all configuration options of this extension.

List operations

You can use handlebars #each to build a full JSON response with the current list's content.

Things to consider:

Example with inline body:

{
  "request": {
    "urlPathPattern": "/listing",
    "method": "GET"
  },
  "response": {
    "status": 200,
    "body": "[\n{{# each (state context='list' property='list' default='[]') }}  {\n    \"id\": \"{{id}}\",\n    \"firstName\": \"{{firstName}}\",\n    \"lastName\": \"{{lastName}}\"  }{{#unless @last}},{{/unless}}\n{{/each}}]",
    "headers": {
      "content-type": "application/json"
    }
  }
}

Example with bodyFileName:

{
  "request": {
    "urlPathPattern": "/listing",
    "method": "GET"
  },
  "response": {
    "status": 200,
    "bodyFileName": "body.json",
    "headers": {
      "content-type": "application/json"
    }
  }
}
[
  {{# each (state context='list' property='list' default='[]') }}  
  {
    "id": {{id}},    
    "firstName": "{{firstName}}",   
    "lastName": "{{lastName}}"
  }{{#unless @last}},{{/unless}}
  {{/each}}
]

Missing properties and defaults

Missing Helper properties as well as unknown context properties result in using a built-in default.

You can also specify a default for the state helper: "clientId": "{{state context=request.pathSegments.[1] property='firstname' default='John'}}", .

If unsure, you may consult the log for to see whether an error occurred.

Properties and their defaults:

Property Built-in Interprets default
updateCount "0" (0 as string) yes
listSize (when context is not present) "0" (0 as string) yes
listSize (when context is present) not applied as list is present but empty not applied as list is present but empty
list (when context is not present) [] (empty list) yes
list (when context is present) not applied as list is present but empty not applied as list is present but empty
any other state property "" (empty string) yes
any other list property "" (empty string) yes

Defaults have to be strings or valid objects in order to result in proper JSONs in all configuration scenarios. In order to create a JSON response with a null property or to ignore unknown properties in your resulting JSON, you may consider using a body file with handlebar logic to create the JSON you need: handlebar interprets an empty string as false.

body file with handlebars to create myProperty=null:

{
{{#with (state context=request.pathSegments.[1] property='myProperty') as | value |}}
"myProperty": "{{value}}"
{{else}}
"myProperty": null
{{/with}}
}

body file with handlebars to ignore a missing property:

{
{{#with (state context=request.pathSegments.[1] property='myProperty') as | value |}}
"myProperty": "{{value}}"
{{else}}
{{/with}}
}

Distributed setups and concurrency

This extension is at the moment not optimized for distributed setups or high degrees concurrency. While it will basically work, there are some limitations that should be held into account:

For any kind of usage with parallel write requests, it's recommended to use a different context for each parallel stream.

Debugging

In general, you can increase verbosity, either by register a notifier and setting verbose=true or starting WireMock standalone (or docker) with verbose=true.

Examples

Various test examples can be found in the tests of this extension.

JSON stub mapping can be found in the resource files of the tests .