Dynamic properties

The contract can contain some dynamic properties: timestamps, IDs, and so on. You do not want to force the consumers to stub their clocks to always return the same value of time so that it gets matched by the stub.

For the Groovy DSL, you can provide the dynamic parts in your contracts in two ways: pass them directly in the body or set them in a separate section called bodyMatchers.

Before 2.0.0, these were set by using testMatchers and stubMatchers. See the migration guide for more information.

For YAML, you can use only the matchers section.

Entries inside the matchers must reference existing elements of the payload. For more information, see this issue.

Dynamic Properties inside the Body

This section is valid only for the Coded DSL (Groovy, Java, and so on). See the Dynamic Properties in the Matchers Sections section for YAML examples of a similar feature.

You can set the properties inside the body either with the value method or, if you use the Groovy map notation, with $(). The following example shows how to set dynamic properties with the value method:

value
value(consumer(...), producer(...))
value(c(...), p(...))
value(stub(...), test(...))
value(client(...), server(...))
$
$(consumer(...), producer(...))
$(c(...), p(...))
$(stub(...), test(...))
$(client(...), server(...))

Both approaches work equally well. The stub and client methods are aliases over the consumer method. Subsequent sections take a closer look at what you can do with those values.

Regular Expressions

This section is valid only for the Groovy DSL. See the Dynamic Properties in the Matchers Sections section for YAML examples of a similar feature.

You can use regular expressions to write your requests in the contract DSL. Doing so is particularly useful when you want to indicate that a given response should be provided for requests that follow a given pattern. Also, you can use regular expressions when you need to use patterns and not exact values both for your tests and your server-side tests.

Make sure that regex matches a whole region of a sequence, as, internally, Pattern.matches() is called. For instance, abc does not match aabc, but .abc does. There are several additional known limitations as well.

The following example shows how to use regular expressions to write a request:

Groovy
org.springframework.cloud.contract.spec.Contract.make {
	request {
		method('GET')
		url $(consumer(~/\/[0-9]{2}/), producer('/12'))
	}
	response {
		status OK()
		body(
				id: $(anyNumber()),
				surname: $(
						consumer('Kowalsky'),
						producer(regex('[a-zA-Z]+'))
				),
				name: 'Jan',
				created: $(consumer('2014-02-02 12:23:43'), producer(execute('currentDate(it)'))),
				correlationId: value(consumer('5d1f9fef-e0dc-4f3d-a7e4-72d2220dd827'),
						producer(regex('[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}'))
				)
		)
		headers {
			header 'Content-Type': 'text/plain'
		}
	}
}
Java
org.springframework.cloud.contract.spec.Contract.make(c -> {
	c.request(r -> {
		r.method("GET");
		r.url(r.$(r.consumer(r.regex("\\/[0-9]{2}")), r.producer("/12")));
	});
	c.response(r -> {
		r.status(r.OK());
		r.body(ContractVerifierUtil.map().entry("id", r.$(r.anyNumber())).entry("surname",
				r.$(r.consumer("Kowalsky"), r.producer(r.regex("[a-zA-Z]+")))));
		r.headers(h -> {
			h.header("Content-Type", "text/plain");
		});
	});
});
Kotlin
contract {
    request {
        method = method("GET")
        url = url(v(consumer(regex("\\/[0-9]{2}")), producer("/12")))
    }
    response {
        status = OK
        body(mapOf(
                "id" to v(anyNumber),
                "surname" to v(consumer("Kowalsky"), producer(regex("[a-zA-Z]+")))
        ))
        headers {
            header("Content-Type", "text/plain")
        }
    }
}

You can also provide only one side of the communication with a regular expression. If you do so, then the contract engine automatically provides the generated string that matches the provided regular expression. The following code shows an example for Groovy:

org.springframework.cloud.contract.spec.Contract.make {
	request {
		method 'PUT'
		url value(consumer(regex('/foo/[0-9]{5}')))
		body([
				requestElement: $(consumer(regex('[0-9]{5}')))
		])
		headers {
			header('header', $(consumer(regex('application\\/vnd\\.fraud\\.v1\\+json;.*'))))
		}
	}
	response {
		status OK()
		body([
				responseElement: $(producer(regex('[0-9]{7}')))
		])
		headers {
			contentType("application/vnd.fraud.v1+json")
		}
	}
}

In the preceding example, the opposite side of the communication has the respective data generated for request and response.

Spring Cloud Contract comes with a series of predefined regular expressions that you can use in your contracts, as the following example shows:

public static RegexProperty onlyAlphaUnicode() {
	return new RegexProperty(ONLY_ALPHA_UNICODE).asString();
}

public static RegexProperty alphaNumeric() {
	return new RegexProperty(ALPHA_NUMERIC).asString();
}

public static RegexProperty number() {
	return new RegexProperty(NUMBER).asDouble();
}

public static RegexProperty positiveInt() {
	return new RegexProperty(POSITIVE_INT).asInteger();
}

public static RegexProperty anyBoolean() {
	return new RegexProperty(TRUE_OR_FALSE).asBooleanType();
}

public static RegexProperty anInteger() {
	return new RegexProperty(INTEGER).asInteger();
}

public static RegexProperty aDouble() {
	return new RegexProperty(DOUBLE).asDouble();
}

public static RegexProperty ipAddress() {
	return new RegexProperty(IP_ADDRESS).asString();
}

public static RegexProperty hostname() {
	return new RegexProperty(HOSTNAME_PATTERN).asString();
}

public static RegexProperty email() {
	return new RegexProperty(EMAIL).asString();
}

public static RegexProperty url() {
	return new RegexProperty(URL).asString();
}

public static RegexProperty httpsUrl() {
	return new RegexProperty(HTTPS_URL).asString();
}

public static RegexProperty uuid() {
	return new RegexProperty(UUID).asString();
}

public static RegexProperty uuid4() {
	return new RegexProperty(UUID4).asString();
}

public static RegexProperty isoDate() {
	return new RegexProperty(ANY_DATE).asString();
}

public static RegexProperty isoDateTime() {
	return new RegexProperty(ANY_DATE_TIME).asString();
}

public static RegexProperty isoTime() {
	return new RegexProperty(ANY_TIME).asString();
}

public static RegexProperty iso8601WithOffset() {
	return new RegexProperty(ISO8601_WITH_OFFSET).asString();
}

public static RegexProperty nonEmpty() {
	return new RegexProperty(NON_EMPTY).asString();
}

public static RegexProperty nonBlank() {
	return new RegexProperty(NON_BLANK).asString();
}

In your contract, you can use it as follows (example for the Groovy DSL):

Contract dslWithOptionalsInString = Contract.make {
	priority 1
	request {
		method POST()
		url '/users/password'
		headers {
			contentType(applicationJson())
		}
		body(
				email: $(consumer(optional(regex(email()))), producer('[email protected]')),
				callback_url: $(consumer(regex(hostname())), producer('http://partners.com'))
		)
	}
	response {
		status 404
		headers {
			contentType(applicationJson())
		}
		body(
				code: value(consumer("123123"), producer(optional("123123"))),
				message: "User not found by email = [${value(producer(regex(email())), consumer('[email protected]'))}]"
		)
	}
}

To make matters even simpler, you can use a set of predefined objects that automatically assume that you want a regular expression to be passed. All of those methods start with the any prefix, as follows:

T anyAlphaUnicode();

T anyAlphaNumeric();

T anyNumber();

T anyInteger();

T anyPositiveInt();

T anyDouble();

T anyHex();

T aBoolean();

T anyIpAddress();

T anyHostname();

T anyEmail();

T anyUrl();

T anyHttpsUrl();

T anyUuid();

T anyDate();

T anyDateTime();

T anyTime();

T anyIso8601WithOffset();

T anyNonBlankString();

T anyNonEmptyString();

T anyOf(String... values);

The following example shows how you can reference those methods:

Groovy
Contract contractDsl = Contract.make {
	name "foo"
	label 'trigger_event'
	input {
		triggeredBy('toString()')
	}
	outputMessage {
		sentTo 'topic.rateablequote'
		body([
				alpha            : $(anyAlphaUnicode()),
				number           : $(anyNumber()),
				anInteger        : $(anyInteger()),
				positiveInt      : $(anyPositiveInt()),
				aDouble          : $(anyDouble()),
				aBoolean         : $(aBoolean()),
				ip               : $(anyIpAddress()),
				hostname         : $(anyHostname()),
				email            : $(anyEmail()),
				url              : $(anyUrl()),
				httpsUrl         : $(anyHttpsUrl()),
				uuid             : $(anyUuid()),
				date             : $(anyDate()),
				dateTime         : $(anyDateTime()),
				time             : $(anyTime()),
				iso8601WithOffset: $(anyIso8601WithOffset()),
				nonBlankString   : $(anyNonBlankString()),
				nonEmptyString   : $(anyNonEmptyString()),
				anyOf            : $(anyOf('foo', 'bar'))
		])
	}
}
Kotlin
contract {
    name = "foo"
    label = "trigger_event"
    input {
        triggeredBy = "toString()"
    }
    outputMessage {
        sentTo = sentTo("topic.rateablequote")
        body(mapOf(
                "alpha" to v(anyAlphaUnicode),
                "number" to v(anyNumber),
                "anInteger" to v(anyInteger),
                "positiveInt" to v(anyPositiveInt),
                "aDouble" to v(anyDouble),
                "aBoolean" to v(aBoolean),
                "ip" to v(anyIpAddress),
                "hostname" to v(anyAlphaUnicode),
                "email" to v(anyEmail),
                "url" to v(anyUrl),
                "httpsUrl" to v(anyHttpsUrl),
                "uuid" to v(anyUuid),
                "date" to v(anyDate),
                "dateTime" to v(anyDateTime),
                "time" to v(anyTime),
                "iso8601WithOffset" to v(anyIso8601WithOffset),
                "nonBlankString" to v(anyNonBlankString),
                "nonEmptyString" to v(anyNonEmptyString),
                "anyOf" to v(anyOf('foo', 'bar'))
        ))
        headers {
            header("Content-Type", "text/plain")
        }
    }
}

Limitations

Due to certain limitations of the Xeger library that generates a string out of a regex, do not use the $ and ^ signs in your regex if you rely on automatic generation. See Issue 899.
Do not use a LocalDate instance as a value for $ (for example, $(consumer(LocalDate.now()))). It causes a java.lang.StackOverflowError. Use $(consumer(LocalDate.now().toString())) instead. See Issue 900.

Passing Optional Parameters

This section is valid only for Groovy DSL. See the Dynamic Properties in the Matchers Sections section for YAML examples of a similar feature.

You can provide optional parameters in your contract. However, you can provide optional parameters only for the following:

  • The STUB side of the Request

  • The TEST side of the Response

The following example shows how to provide optional parameters:

Groovy
org.springframework.cloud.contract.spec.Contract.make {
	priority 1
	name "optionals"
	request {
		method 'POST'
		url '/users/password'
		headers {
			contentType(applicationJson())
		}
		body(
				email: $(consumer(optional(regex(email()))), producer('[email protected]')),
				callback_url: $(consumer(regex(hostname())), producer('https://partners.com'))
		)
	}
	response {
		status 404
		headers {
			header 'Content-Type': 'application/json'
		}
		body(
				code: value(consumer("123123"), producer(optional("123123")))
		)
	}
}
Java
org.springframework.cloud.contract.spec.Contract.make(c -> {
	c.priority(1);
	c.name("optionals");
	c.request(r -> {
		r.method("POST");
		r.url("/users/password");
		r.headers(h -> {
			h.contentType(h.applicationJson());
		});
		r.body(ContractVerifierUtil.map()
				.entry("email", r.$(r.consumer(r.optional(r.regex(r.email()))), r.producer("[email protected]")))
				.entry("callback_url",
						r.$(r.consumer(r.regex(r.hostname())), r.producer("https://partners.com"))));
	});
	c.response(r -> {
		r.status(404);
		r.headers(h -> {
			h.header("Content-Type", "application/json");
		});
		r.body(ContractVerifierUtil.map().entry("code",
				r.value(r.consumer("123123"), r.producer(r.optional("123123")))));
	});
});
Kotlin
contract { c ->
    priority = 1
    name = "optionals"
    request {
        method = POST
        url = url("/users/password")
        headers {
            contentType = APPLICATION_JSON
        }
        body = body(mapOf(
                "email" to v(consumer(optional(regex(email))), producer("[email protected]")),
                "callback_url" to v(consumer(regex(hostname)), producer("https://partners.com"))
        ))
    }
    response {
        status = NOT_FOUND
        headers {
            header("Content-Type", "application/json")
        }
        body(mapOf(
                "code" to value(consumer("123123"), producer(optional("123123")))
        ))
    }
}

By wrapping a part of the body with the optional() method, you create a regular expression that must be present 0 or more times.

If you use Spock, the following test would be generated from the previous example:

Groovy
import com.jayway.jsonpath.DocumentContext
import com.jayway.jsonpath.JsonPath
import spock.lang.Specification
import io.restassured.module.mockmvc.specification.MockMvcRequestSpecification
import io.restassured.response.ResponseOptions

import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat
import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson
import static io.restassured.module.mockmvc.RestAssuredMockMvc.*

class FooSpec extends Specification {

\tdef validate_optionals() throws Exception {
\t\tgiven:
\t\t\tMockMvcRequestSpecification request = given()
\t\t\t\t\t.header("Content-Type", "application/json")
\t\t\t\t\t.body('''{"email":"[email protected]","callback_url":"https://partners.com"}''')

\t\twhen:
\t\t\tResponseOptions response = given().spec(request)
\t\t\t\t\t.post("/users/password")

\t\tthen:
\t\t\tresponse.statusCode() == 404
\t\t\tresponse.header("Content-Type") == 'application/json'

\t\tand:
\t\t\tDocumentContext parsedJson = JsonPath.parse(response.body.asString())
\t\t\tassertThatJson(parsedJson).field("['code']").matches("(123123)?")
\t}

}

The following stub would also be generated:

					'''
{
  "request" : {
	"url" : "/users/password",
	"method" : "POST",
	"bodyPatterns" : [ {
	  "matchesJsonPath" : "$[?(@.['email'] =~ /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,6})?/)]"
	}, {
	  "matchesJsonPath" : "$[?(@.['callback_url'] =~ /((http[s]?|ftp):\\\\/)\\\\/?([^:\\\\/\\\\s]+)(:[0-9]{1,5})?/)]"
	} ],
	"headers" : {
	  "Content-Type" : {
		"equalTo" : "application/json"
	  }
	}
  },
  "response" : {
	"status" : 404,
	"body" : "{\\"code\\":\\"123123\\",\\"message\\":\\"User not found by email == [[email protected]]\\"}",
	"headers" : {
	  "Content-Type" : "application/json"
	}
  },
  "priority" : 1
}
'''

Calling Custom Methods on the Server Side

This section is valid only for the Groovy DSL. See the Dynamic Properties in the Matchers Sections section for YAML examples of a similar feature.

You can define a method call that runs on the server side during the test. Such a method can be added to the class defined as baseClassForTests in the configuration. The following code shows an example of the contract portion of the test case:

Groovy
method GET()
Java
r.method(r.GET());
Kotlin
method = GET

The following code shows the base class portion of the test case:

abstract class BaseMockMvcSpec extends Specification {

	def setup() {
		RestAssuredMockMvc.standaloneSetup(new PairIdController())
	}

	void isProperCorrelationId(Integer correlationId) {
		assert correlationId == 123456
	}

	void isEmpty(String value) {
		assert value == null
	}

}
You cannot use both a String and execute to perform concatenation. For example, calling header('Authorization', 'Bearer ' + execute('authToken()')) leads to improper results. Instead, call header('Authorization', execute('authToken()')) and ensure that the authToken() method returns everything you need.

The type of the object read from the JSON can be one of the following, depending on the JSON path:

  • String: If you point to a String value in the JSON.

  • JSONArray: If you point to a List in the JSON.

  • Map: If you point to a Map in the JSON.

  • Number: If you point to Integer, Double, and other numeric type in the JSON.

  • Boolean: If you point to a Boolean in the JSON.

In the request part of the contract, you can specify that the body should be taken from a method.

You must provide both the consumer and the producer side. The execute part is applied for the whole body, not for parts of it.

The following example shows how to read an object from JSON:

Contract contractDsl = Contract.make {
	request {
		method 'GET'
		url '/something'
		body(
				$(c('foo'), p(execute('hashCode()')))
		)
	}
	response {
		status OK()
	}
}

The preceding example results in calling the hashCode() method in the request body. It should resemble the following code:

// given:
 MockMvcRequestSpecification request = given()
   .body(hashCode());

// when:
 ResponseOptions response = given().spec(request)
   .get("/something");

// then:
 assertThat(response.statusCode()).isEqualTo(200);

Referencing the Request from the Response

The best situation is to provide fixed values, but sometimes you need to reference a request in your response.

If you write contracts in the Groovy DSL, you can use the fromRequest() method, which lets you reference a bunch of elements from the HTTP request. You can use the following options:

  • fromRequest().url(): Returns the request URL and query parameters.

  • fromRequest().query(String key): Returns the first query parameter with the given name.

  • fromRequest().query(String key, int index): Returns the nth query parameter with the given name.

  • fromRequest().path(): Returns the full path.

  • fromRequest().path(int index): Returns the nth path element.

  • fromRequest().header(String key): Returns the first header with the given name.

  • fromRequest().header(String key, int index): Returns the nth header with the given name.

  • fromRequest().body(): Returns the full request body.

  • fromRequest().body(String jsonPath): Returns the element from the request that matches the JSON Path.

If you use the YAML contract definition or the Java one, you have to use the Handlebars {{{ }}} notation with custom Spring Cloud Contract functions to achieve this. In that case, you can use the following options:

  • {{{ request.url }}}: Returns the request URL and query parameters.

  • {{{ request.query.key.[index] }}}: Returns the nth query parameter with the given name. For example, for a key of thing, the first entry is {{{ request.query.thing.[0] }}}

  • {{{ request.path }}}: Returns the full path.

  • {{{ request.path.[index] }}}: Returns the nth path element. For example, the first entry is `{{{ request.path.[0] }}}

  • {{{ request.headers.key }}}: Returns the first header with the given name.

  • {{{ request.headers.key.[index] }}}: Returns the nth header with the given name.

  • {{{ request.body }}}: Returns the full request body.

  • {{{ jsonpath this 'your.json.path' }}}: Returns the element from the request that matches the JSON Path. For example, for a JSON path of $.here, use {{{ jsonpath this '$.here' }}}

Consider the following contract:

Groovy
Contract contractDsl = Contract.make {
	request {
		method 'GET'
		url('/api/v1/xxxx') {
			queryParameters {
				parameter('foo', 'bar')
				parameter('foo', 'bar2')
			}
		}
		headers {
			header(authorization(), 'secret')
			header(authorization(), 'secret2')
		}
		body(foo: 'bar', baz: 5)
	}
	response {
		status OK()
		headers {
			header(authorization(), "foo ${fromRequest().header(authorization())} bar")
		}
		body(
				url: fromRequest().url(),
				path: fromRequest().path(),
				pathIndex: fromRequest().path(1),
				param: fromRequest().query('foo'),
				paramIndex: fromRequest().query('foo', 1),
				authorization: fromRequest().header('Authorization'),
				authorization2: fromRequest().header('Authorization', 1),
				fullBody: fromRequest().body(),
				responseFoo: fromRequest().body('$.foo'),
				responseBaz: fromRequest().body('$.baz'),
				responseBaz2: "Bla bla ${fromRequest().body('$.foo')} bla bla",
				rawUrl: fromRequest().rawUrl(),
				rawPath: fromRequest().rawPath(),
				rawPathIndex: fromRequest().rawPath(1),
				rawParam: fromRequest().rawQuery('foo'),
				rawParamIndex: fromRequest().rawQuery('foo', 1),
				rawAuthorization: fromRequest().rawHeader('Authorization'),
				rawAuthorization2: fromRequest().rawHeader('Authorization', 1),
				rawResponseFoo: fromRequest().rawBody('$.foo'),
				rawResponseBaz: fromRequest().rawBody('$.baz'),
				rawResponseBaz2: "Bla bla ${fromRequest().rawBody('$.foo')} bla bla"
		)
	}
}
Contract contractDsl = Contract.make {
	request {
		method 'GET'
		url('/api/v1/xxxx') {
			queryParameters {
				parameter('foo', 'bar')
				parameter('foo', 'bar2')
			}
		}
		headers {
			header(authorization(), 'secret')
			header(authorization(), 'secret2')
		}
		body(foo: "bar", baz: 5)
	}
	response {
		status OK()
		headers {
			contentType(applicationJson())
		}
		body(''' 
				{
					"responseFoo": "{{{ jsonPath request.body '$.foo' }}}",
					"responseBaz": {{{ jsonPath request.body '$.baz' }}},
					"responseBaz2": "Bla bla {{{ jsonPath request.body '$.foo' }}} bla bla"
				}
		'''.toString())
	}
}
YAML
request:
  method: GET
  url: /api/v1/xxxx
  queryParameters:
    foo:
      - bar
      - bar2
  headers:
    Authorization:
      - secret
      - secret2
  body:
    foo: bar
    baz: 5
response:
  status: 200
  headers:
    Authorization: "foo {{{ request.headers.Authorization.0 }}} bar"
  body:
    url: "{{{ request.url }}}"
    path: "{{{ request.path }}}"
    pathIndex: "{{{ request.path.1 }}}"
    param: "{{{ request.query.foo }}}"
    paramIndex: "{{{ request.query.foo.1 }}}"
    authorization: "{{{ request.headers.Authorization.0 }}}"
    authorization2: "{{{ request.headers.Authorization.1 }}"
    fullBody: "{{{ request.body }}}"
    responseFoo: "{{{ jsonpath this '$.foo' }}}"
    responseBaz: "{{{ jsonpath this '$.baz' }}}"
    responseBaz2: "Bla bla {{{ jsonpath this '$.foo' }}} bla bla"
Java
import java.util.function.Supplier;

import org.springframework.cloud.contract.spec.Contract;

import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.map;

class shouldReturnStatsForAUser implements Supplier<Contract> {

	@Override
	public Contract get() {
		return Contract.make(c -> {
			c.request(r -> {
				r.method("POST");
				r.url("/stats");
				r.body(map().entry("name", r.anyAlphaUnicode()));
				r.headers(h -> {
					h.contentType(h.applicationJson());
				});
			});
			c.response(r -> {
				r.status(r.OK());
				r.body(map()
						.entry("text",
								"Dear {{{jsonPath request.body '$.name'}}} thanks for your interested in drinking beer")
						.entry("quantity", r.$(r.c(5), r.p(r.anyNumber()))));
				r.headers(h -> {
					h.contentType(h.applicationJson());
				});
			});
		});
	}

}
Kotlin
import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract

contract {
    request {
        method = method("POST")
        url = url("/stats")
        body(mapOf(
            "name" to anyAlphaUnicode
        ))
        headers {
            contentType = APPLICATION_JSON
        }
    }
    response {
        status = OK
        body(mapOf(
            "text" to "Don't worry $\{fromRequest().body("$.name")} thanks for your interested in drinking beer",
            "quantity" to v(c(5), p(anyNumber))
        ))
        headers {
            contentType = fromRequest().header(CONTENT_TYPE)
        }
    }
}

Running a JUnit test generation leads to a test that resembles the following example:

// given:
 MockMvcRequestSpecification request = given()
   .header("Authorization", "secret")
   .header("Authorization", "secret2")
   .body("{\"foo\":\"bar\",\"baz\":5}");

// when:
 ResponseOptions response = given().spec(request)
   .queryParam("foo","bar")
   .queryParam("foo","bar2")
   .get("/api/v1/xxxx");

// then:
 assertThat(response.statusCode()).isEqualTo(200);
 assertThat(response.header("Authorization")).isEqualTo("foo secret bar");
// and:
 DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
 assertThatJson(parsedJson).field("['fullBody']").isEqualTo("{\"foo\":\"bar\",\"baz\":5}");
 assertThatJson(parsedJson).field("['authorization']").isEqualTo("secret");
 assertThatJson(parsedJson).field("['authorization2']").isEqualTo("secret2");
 assertThatJson(parsedJson).field("['path']").isEqualTo("/api/v1/xxxx");
 assertThatJson(parsedJson).field("['param']").isEqualTo("bar");
 assertThatJson(parsedJson).field("['paramIndex']").isEqualTo("bar2");
 assertThatJson(parsedJson).field("['pathIndex']").isEqualTo("v1");
 assertThatJson(parsedJson).field("['responseBaz']").isEqualTo(5);
 assertThatJson(parsedJson).field("['responseFoo']").isEqualTo("bar");
 assertThatJson(parsedJson).field("['url']").isEqualTo("/api/v1/xxxx?foo=bar&foo=bar2");
 assertThatJson(parsedJson).field("['responseBaz2']").isEqualTo("Bla bla bar bla bla");

As you can see, elements from the request have been properly referenced in the response.

The generated WireMock stub should resemble the following example:

{
  "request" : {
    "urlPath" : "/api/v1/xxxx",
    "method" : "POST",
    "headers" : {
      "Authorization" : {
        "equalTo" : "secret2"
      }
    },
    "queryParameters" : {
      "foo" : {
        "equalTo" : "bar2"
      }
    },
    "bodyPatterns" : [ {
      "matchesJsonPath" : "$[?(@.['baz'] == 5)]"
    }, {
      "matchesJsonPath" : "$[?(@.['foo'] == 'bar')]"
    } ]
  },
  "response" : {
    "status" : 200,
    "body" : "{\"authorization\":\"{{{request.headers.Authorization.[0]}}}\",\"path\":\"{{{request.path}}}\",\"responseBaz\":{{{jsonpath this '$.baz'}}} ,\"param\":\"{{{request.query.foo.[0]}}}\",\"pathIndex\":\"{{{request.path.[1]}}}\",\"responseBaz2\":\"Bla bla {{{jsonpath this '$.foo'}}} bla bla\",\"responseFoo\":\"{{{jsonpath this '$.foo'}}}\",\"authorization2\":\"{{{request.headers.Authorization.[1]}}}\",\"fullBody\":\"{{{escapejsonbody}}}\",\"url\":\"{{{request.url}}}\",\"paramIndex\":\"{{{request.query.foo.[1]}}}\"}",
    "headers" : {
      "Authorization" : "{{{request.headers.Authorization.[0]}}};foo"
    },
    "transformers" : [ "response-template" ]
  }
}

Sending a request such as the one presented in the request part of the contract results in sending the following response body:

{
  "url" : "/api/v1/xxxx?foo=bar&foo=bar2",
  "path" : "/api/v1/xxxx",
  "pathIndex" : "v1",
  "param" : "bar",
  "paramIndex" : "bar2",
  "authorization" : "secret",
  "authorization2" : "secret2",
  "fullBody" : "{\"foo\":\"bar\",\"baz\":5}",
  "responseFoo" : "bar",
  "responseBaz" : 5,
  "responseBaz2" : "Bla bla bar bla bla"
}
This feature works only with WireMock versions greater than or equal to 2.5.1. The Spring Cloud Contract Verifier uses WireMock’s response-template response transformer. It uses Handlebars to convert the Mustache {{{ }}} templates into proper values. Additionally, it registers two helper functions:
  • escapejsonbody: Escapes the request body in a format that can be embedded in JSON.

  • jsonpath: For a given parameter, finds an object in the request body.

Dynamic Properties in the Matchers Sections

If you work with Pact, the following discussion may seem familiar. Quite a few users are used to having a separation between the body and setting the dynamic parts of a contract.

You can use the bodyMatchers section for two reasons:

  • Define the dynamic values that should end up in a stub. You can set it in the request part of your contract.

  • Verify the result of your test. This section is present in the response or outputMessage side of the contract.

Currently, Spring Cloud Contract Verifier supports only JSON path-based matchers with the following matching possibilities:

Coded DSL

For the stubs (in tests on the consumer’s side):

  • byEquality(): The value taken from the consumer’s request in the provided JSON path must be equal to the value provided in the contract.

  • byRegex(…​): The value taken from the consumer’s request in the provided JSON path must match the regex. You can also pass the type of the expected matched value (for example, asString(), asLong(), and so on).

  • byDate(): The value taken from the consumer’s request in the provided JSON path must match the regex for an ISO Date value.

  • byTimestamp(): The value taken from the consumer’s request in the provided JSON path must match the regex for an ISO DateTime value.

  • byTime(): The value taken from the consumer’s request in the provided JSON path must match the regex for an ISO Time value.

For the verification (in generated tests on the Producer’s side):

  • byEquality(): The value taken from the producer’s response in the provided JSON path must be equal to the provided value in the contract.

  • byRegex(…​): The value taken from the producer’s response in the provided JSON path must match the regex.

  • byDate(): The value taken from the producer’s response in the provided JSON path must match the regex for an ISO Date value.

  • byTimestamp(): The value taken from the producer’s response in the provided JSON path must match the regex for an ISO DateTime value.

  • byTime(): The value taken from the producer’s response in the provided JSON path must match the regex for an ISO Time value.

  • byType(): The value taken from the producer’s response in the provided JSON path needs to be of the same type as the type defined in the body of the response in the contract. byType can take a closure, in which you can set minOccurrence and maxOccurrence. For the request side, you should use the closure to assert size of the collection. That way, you can assert the size of the flattened collection. To check the size of an unflattened collection, use a custom method with the byCommand(…​) testMatcher.

  • byCommand(…​): The value taken from the producer’s response in the provided JSON path is passed as an input to the custom method that you provide. For example, byCommand('thing($it)') results in calling a thing method to which the value matching the JSON Path gets passed. The type of the object read from the JSON can be one of the following, depending on the JSON path:

    • String: If you point to a String value.

    • JSONArray: If you point to a List.

    • Map: If you point to a Map.

    • Number: If you point to Integer, Double, or another kind of number.

    • Boolean: If you point to a Boolean.

  • byNull(): The value taken from the response in the provided JSON path must be null.

YAML

See the Groovy section for a detailed explanation of what the types mean.

For YAML, the structure of a matcher resembles the following example:

- path: $.thing1
  type: by_regex
  value: thing2
  regexType: as_string

Alternatively, if you want to use one of the predefined regular expressions [only_alpha_unicode, number, any_boolean, ip_address, hostname, email, url, uuid, iso_date, iso_date_time, iso_time, iso_8601_with_offset, non_empty, non_blank], you can use something similar to the following example:

- path: $.thing1
  type: by_regex
  predefined: only_alpha_unicode

The following list shows the allowed list of type values:

  • For stubMatchers:

    • by_equality

    • by_regex

    • by_date

    • by_timestamp

    • by_time

    • by_type

      • Two additional fields (minOccurrence and maxOccurrence) are accepted.

  • For testMatchers:

    • by_equality

    • by_regex

    • by_date

    • by_timestamp

    • by_time

    • by_type

      • Two additional fields (minOccurrence and maxOccurrence) are accepted.

    • by_command

    • by_null

You can also define which type the regular expression corresponds to in the regexType field. The following list shows the allowed regular expression types:

  • as_integer

  • as_double

  • as_float

  • as_long

  • as_short

  • as_boolean

  • as_string

Consider the following example:

Groovy
Contract contractDsl = Contract.make {
	request {
		method 'GET'
		urlPath '/get'
		body([
				duck                : 123,
				alpha               : 'abc',
				number              : 123,
				aBoolean            : true,
				date                : '2017-01-01',
				dateTime            : '2017-01-01T01:23:45',
				time                : '01:02:34',
				valueWithoutAMatcher: 'foo',
				valueWithTypeMatch  : 'string',
				key                 : [
						'complex.key': 'foo'
				]
		])
		bodyMatchers {
			jsonPath('$.duck', byRegex("[0-9]{3}").asInteger())
			jsonPath('$.duck', byEquality())
			jsonPath('$.alpha', byRegex(onlyAlphaUnicode()).asString())
			jsonPath('$.alpha', byEquality())
			jsonPath('$.number', byRegex(number()).asInteger())
			jsonPath('$.aBoolean', byRegex(anyBoolean()).asBooleanType())
			jsonPath('$.date', byDate())
			jsonPath('$.dateTime', byTimestamp())
			jsonPath('$.time', byTime())
			jsonPath("\$.['key'].['complex.key']", byEquality())
		}
		headers {
			contentType(applicationJson())
		}
	}
	response {
		status OK()
		body([
				duck                 : 123,
				alpha                : 'abc',
				number               : 123,
				positiveInteger      : 1234567890,
				negativeInteger      : -1234567890,
				positiveDecimalNumber: 123.4567890,
				negativeDecimalNumber: -123.4567890,
				aBoolean             : true,
				date                 : '2017-01-01',
				dateTime             : '2017-01-01T01:23:45',
				time                 : "01:02:34",
				valueWithoutAMatcher : 'foo',
				valueWithTypeMatch   : 'string',
				valueWithMin         : [
						1, 2, 3
				],
				valueWithMax         : [
						1, 2, 3
				],
				valueWithMinMax      : [
						1, 2, 3
				],
				valueWithMinEmpty    : [],
				valueWithMaxEmpty    : [],
				key                  : [
						'complex.key': 'foo'
				],
				nullValue            : null
		])
		bodyMatchers {
			// asserts the jsonpath value against manual regex
			jsonPath('$.duck', byRegex("[0-9]{3}").asInteger())
			// asserts the jsonpath value against the provided value
			jsonPath('$.duck', byEquality())
			// asserts the jsonpath value against some default regex
			jsonPath('$.alpha', byRegex(onlyAlphaUnicode()).asString())
			jsonPath('$.alpha', byEquality())
			jsonPath('$.number', byRegex(number()).asInteger())
			jsonPath('$.positiveInteger', byRegex(anInteger()).asInteger())
			jsonPath('$.negativeInteger', byRegex(anInteger()).asInteger())
			jsonPath('$.positiveDecimalNumber', byRegex(aDouble()).asDouble())
			jsonPath('$.negativeDecimalNumber', byRegex(aDouble()).asDouble())
			jsonPath('$.aBoolean', byRegex(anyBoolean()).asBooleanType())
			// asserts vs inbuilt time related regex
			jsonPath('$.date', byDate())
			jsonPath('$.dateTime', byTimestamp())
			jsonPath('$.time', byTime())
			// asserts that the resulting type is the same as in response body
			jsonPath('$.valueWithTypeMatch', byType())
			jsonPath('$.valueWithMin', byType {
				// results in verification of size of array (min 1)
				minOccurrence(1)
			})
			jsonPath('$.valueWithMax', byType {
				// results in verification of size of array (max 3)
				maxOccurrence(3)
			})
			jsonPath('$.valueWithMinMax', byType {
				// results in verification of size of array (min 1 & max 3)
				minOccurrence(1)
				maxOccurrence(3)
			})
			jsonPath('$.valueWithMinEmpty', byType {
				// results in verification of size of array (min 0)
				minOccurrence(0)
			})
			jsonPath('$.valueWithMaxEmpty', byType {
				// results in verification of size of array (max 0)
				maxOccurrence(0)
			})
			// will execute a method `assertThatValueIsANumber`
			jsonPath('$.duck', byCommand('assertThatValueIsANumber($it)'))
			jsonPath("\$.['key'].['complex.key']", byEquality())
			jsonPath('$.nullValue', byNull())
		}
		headers {
			contentType(applicationJson())
			header('Some-Header', $(c('someValue'), p(regex('[a-zA-Z]{9}'))))
		}
	}
}
YAML
request:
  method: GET
  urlPath: /get/1
  headers:
    Content-Type: application/json
  cookies:
    foo: 2
    bar: 3
  queryParameters:
    limit: 10
    offset: 20
    filter: 'email'
    sort: name
    search: 55
    age: 99
    name: John.Doe
    email: '[email protected]'
  body:
    duck: 123
    alpha: "abc"
    number: 123
    aBoolean: true
    date: "2017-01-01"
    dateTime: "2017-01-01T01:23:45"
    time: "01:02:34"
    valueWithoutAMatcher: "foo"
    valueWithTypeMatch: "string"
    key:
      "complex.key": 'foo'
    nullValue: null
    valueWithMin:
      - 1
      - 2
      - 3
    valueWithMax:
      - 1
      - 2
      - 3
    valueWithMinMax:
      - 1
      - 2
      - 3
    valueWithMinEmpty: []
    valueWithMaxEmpty: []
  matchers:
    url:
      regex: /get/[0-9]
      # predefined:
      # execute a method
      #command: 'equals($it)'
    queryParameters:
      - key: limit
        type: equal_to
        value: 20
      - key: offset
        type: containing
        value: 20
      - key: sort
        type: equal_to
        value: name
      - key: search
        type: not_matching
        value: '^[0-9]{2}$'
      - key: age
        type: not_matching
        value: '^\\w*$'
      - key: name
        type: matching
        value: 'John.*'
      - key: hello
        type: absent
    cookies:
      - key: foo
        regex: '[0-9]'
      - key: bar
        command: 'equals($it)'
    headers:
      - key: Content-Type
        regex: "application/json.*"
    body:
      - path: $.duck
        type: by_regex
        value: "[0-9]{3}"
      - path: $.duck
        type: by_equality
      - path: $.alpha
        type: by_regex
        predefined: only_alpha_unicode
      - path: $.alpha
        type: by_equality
      - path: $.number
        type: by_regex
        predefined: number
      - path: $.aBoolean
        type: by_regex
        predefined: any_boolean
      - path: $.date
        type: by_date
      - path: $.dateTime
        type: by_timestamp
      - path: $.time
        type: by_time
      - path: "$.['key'].['complex.key']"
        type: by_equality
      - path: $.nullvalue
        type: by_null
      - path: $.valueWithMin
        type: by_type
        minOccurrence: 1
      - path: $.valueWithMax
        type: by_type
        maxOccurrence: 3
      - path: $.valueWithMinMax
        type: by_type
        minOccurrence: 1
        maxOccurrence: 3
response:
  status: 200
  cookies:
    foo: 1
    bar: 2
  body:
    duck: 123
    alpha: "abc"
    number: 123
    aBoolean: true
    date: "2017-01-01"
    dateTime: "2017-01-01T01:23:45"
    time: "01:02:34"
    valueWithoutAMatcher: "foo"
    valueWithTypeMatch: "string"
    valueWithMin:
      - 1
      - 2
      - 3
    valueWithMax:
      - 1
      - 2
      - 3
    valueWithMinMax:
      - 1
      - 2
      - 3
    valueWithMinEmpty: []
    valueWithMaxEmpty: []
    key:
      'complex.key': 'foo'
    nulValue: null
  matchers:
    headers:
      - key: Content-Type
        regex: "application/json.*"
    cookies:
      - key: foo
        regex: '[0-9]'
      - key: bar
        command: 'equals($it)'
    body:
      - path: $.duck
        type: by_regex
        value: "[0-9]{3}"
      - path: $.duck
        type: by_equality
      - path: $.alpha
        type: by_regex
        predefined: only_alpha_unicode
      - path: $.alpha
        type: by_equality
      - path: $.number
        type: by_regex
        predefined: number
      - path: $.aBoolean
        type: by_regex
        predefined: any_boolean
      - path: $.date
        type: by_date
      - path: $.dateTime
        type: by_timestamp
      - path: $.time
        type: by_time
      - path: $.valueWithTypeMatch
        type: by_type
      - path: $.valueWithMin
        type: by_type
        minOccurrence: 1
      - path: $.valueWithMax
        type: by_type
        maxOccurrence: 3
      - path: $.valueWithMinMax
        type: by_type
        minOccurrence: 1
        maxOccurrence: 3
      - path: $.valueWithMinEmpty
        type: by_type
        minOccurrence: 0
      - path: $.valueWithMaxEmpty
        type: by_type
        maxOccurrence: 0
      - path: $.duck
        type: by_command
        value: assertThatValueIsANumber($it)
      - path: $.nullValue
        type: by_null
        value: null
  headers:
    Content-Type: application/json

In the preceding example, you can see the dynamic portions of the contract in the matchers sections. For the request part, you can see that, for all fields but valueWithoutAMatcher, the values of the regular expressions that the stub should contain are explicitly set. For valueWithoutAMatcher, the verification takes place in the same way as without the use of matchers. In that case, the test performs an equality check.

For the response side in the bodyMatchers section, we define the dynamic parts in a similar manner. The only difference is that the byType matchers are also present. The verifier engine checks four fields to verify whether the response from the test has a value for which the JSON path matches the given field, is of the same type as the one defined in the response body, and passes the following check (based on the method being called):

  • For $.valueWithTypeMatch, the engine checks whether the type is the same.

  • For $.valueWithMin, the engine checks the type and asserts whether the size is greater than or equal to the minimum occurrence.

  • For $.valueWithMax, the engine checks the type and asserts whether the size is smaller than or equal to the maximum occurrence.

  • For $.valueWithMinMax, the engine checks the type and asserts whether the size is between the minimum and maximum occurrence.

The resulting test resembles the following example (note that an and section separates the autogenerated assertions and the assertion from matchers):

// given:
 MockMvcRequestSpecification request = given()
   .header("Content-Type", "application/json")
   .body("{\"duck\":123,\"alpha\":\"abc\",\"number\":123,\"aBoolean\":true,\"date\":\"2017-01-01\",\"dateTime\":\"2017-01-01T01:23:45\",\"time\":\"01:02:34\",\"valueWithoutAMatcher\":\"foo\",\"valueWithTypeMatch\":\"string\",\"key\":{\"complex.key\":\"foo\"}}");

// when:
 ResponseOptions response = given().spec(request)
   .get("/get");

// then:
 assertThat(response.statusCode()).isEqualTo(200);
 assertThat(response.header("Content-Type")).matches("application/json.*");
// and:
 DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
 assertThatJson(parsedJson).field("['valueWithoutAMatcher']").isEqualTo("foo");
// and:
 assertThat(parsedJson.read("$.duck", String.class)).matches("[0-9]{3}");
 assertThat(parsedJson.read("$.duck", Integer.class)).isEqualTo(123);
 assertThat(parsedJson.read("$.alpha", String.class)).matches("[\\p{L}]*");
 assertThat(parsedJson.read("$.alpha", String.class)).isEqualTo("abc");
 assertThat(parsedJson.read("$.number", String.class)).matches("-?(\\d*\\.\\d+|\\d+)");
 assertThat(parsedJson.read("$.aBoolean", String.class)).matches("(true|false)");
 assertThat(parsedJson.read("$.date", String.class)).matches("(\\d\\d\\d\\d)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])");
 assertThat(parsedJson.read("$.dateTime", String.class)).matches("([0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])");
 assertThat(parsedJson.read("$.time", String.class)).matches("(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])");
 assertThat((Object) parsedJson.read("$.valueWithTypeMatch")).isInstanceOf(java.lang.String.class);
 assertThat((Object) parsedJson.read("$.valueWithMin")).isInstanceOf(java.util.List.class);
 assertThat((java.lang.Iterable) parsedJson.read("$.valueWithMin", java.util.Collection.class)).as("$.valueWithMin").hasSizeGreaterThanOrEqualTo(1);
 assertThat((Object) parsedJson.read("$.valueWithMax")).isInstanceOf(java.util.List.class);
 assertThat((java.lang.Iterable) parsedJson.read("$.valueWithMax", java.util.Collection.class)).as("$.valueWithMax").hasSizeLessThanOrEqualTo(3);
 assertThat((Object) parsedJson.read("$.valueWithMinMax")).isInstanceOf(java.util.List.class);
 assertThat((java.lang.Iterable) parsedJson.read("$.valueWithMinMax", java.util.Collection.class)).as("$.valueWithMinMax").hasSizeBetween(1, 3);
 assertThat((Object) parsedJson.read("$.valueWithMinEmpty")).isInstanceOf(java.util.List.class);
 assertThat((java.lang.Iterable) parsedJson.read("$.valueWithMinEmpty", java.util.Collection.class)).as("$.valueWithMinEmpty").hasSizeGreaterThanOrEqualTo(0);
 assertThat((Object) parsedJson.read("$.valueWithMaxEmpty")).isInstanceOf(java.util.List.class);
 assertThat((java.lang.Iterable) parsedJson.read("$.valueWithMaxEmpty", java.util.Collection.class)).as("$.valueWithMaxEmpty").hasSizeLessThanOrEqualTo(0);
 assertThatValueIsANumber(parsedJson.read("$.duck"));
 assertThat(parsedJson.read("$.['key'].['complex.key']", String.class)).isEqualTo("foo");
Notice that, for the byCommand method, the example calls the assertThatValueIsANumber. This method must be defined in the test base class or be statically imported to your tests. Notice that the byCommand call was converted to assertThatValueIsANumber(parsedJson.read("$.duck"));. That means that the engine took the method name and passed the proper JSON path as a parameter to it.

The resulting WireMock stub is in the following example:

					'''
{
  "request" : {
    "urlPath" : "/get",
    "method" : "POST",
    "headers" : {
      "Content-Type" : {
        "matches" : "application/json.*"
      }
    },
    "bodyPatterns" : [ {
      "matchesJsonPath" : "$.['list'].['some'].['nested'][?(@.['anothervalue'] == 4)]"
    }, {
      "matchesJsonPath" : "$[?(@.['valueWithoutAMatcher'] == 'foo')]"
    }, {
      "matchesJsonPath" : "$[?(@.['valueWithTypeMatch'] == 'string')]"
    }, {
      "matchesJsonPath" : "$.['list'].['someother'].['nested'][?(@.['json'] == 'with value')]"
    }, {
      "matchesJsonPath" : "$.['list'].['someother'].['nested'][?(@.['anothervalue'] == 4)]"
    }, {
      "matchesJsonPath" : "$[?(@.duck =~ /([0-9]{3})/)]"
    }, {
      "matchesJsonPath" : "$[?(@.duck == 123)]"
    }, {
      "matchesJsonPath" : "$[?(@.alpha =~ /([\\\\p{L}]*)/)]"
    }, {
      "matchesJsonPath" : "$[?(@.alpha == 'abc')]"
    }, {
      "matchesJsonPath" : "$[?(@.number =~ /(-?(\\\\d*\\\\.\\\\d+|\\\\d+))/)]"
    }, {
      "matchesJsonPath" : "$[?(@.aBoolean =~ /((true|false))/)]"
    }, {
      "matchesJsonPath" : "$[?(@.date =~ /((\\\\d\\\\d\\\\d\\\\d)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01]))/)]"
    }, {
      "matchesJsonPath" : "$[?(@.dateTime =~ /(([0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9]))/)]"
    }, {
      "matchesJsonPath" : "$[?(@.time =~ /((2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9]))/)]"
    }, {
      "matchesJsonPath" : "$.list.some.nested[?(@.json =~ /(.*)/)]"
    }, {
      "matchesJsonPath" : "$[?(@.valueWithMin.size() >= 1)]"
    }, {
      "matchesJsonPath" : "$[?(@.valueWithMax.size() <= 3)]"
    }, {
      "matchesJsonPath" : "$[?(@.valueWithMinMax.size() >= 1 && @.valueWithMinMax.size() <= 3)]"
    }, {
      "matchesJsonPath" : "$[?(@.valueWithOccurrence.size() >= 4 && @.valueWithOccurrence.size() <= 4)]"
    } ]
  },
  "response" : {
    "status" : 200,
    "body" : "{\\"duck\\":123,\\"alpha\\":\\"abc\\",\\"number\\":123,\\"aBoolean\\":true,\\"date\\":\\"2017-01-01\\",\\"dateTime\\":\\"2017-01-01T01:23:45\\",\\"time\\":\\"01:02:34\\",\\"valueWithoutAMatcher\\":\\"foo\\",\\"valueWithTypeMatch\\":\\"string\\",\\"valueWithMin\\":[1,2,3],\\"valueWithMax\\":[1,2,3],\\"valueWithMinMax\\":[1,2,3],\\"valueWithOccurrence\\":[1,2,3,4]}",
    "headers" : {
      "Content-Type" : "application/json"
    },
    "transformers" : [ "response-template", "spring-cloud-contract" ]
  }
}
'''
If you use a matcher, the part of the request and response that the matcher addresses with the JSON Path gets removed from the assertion. In the case of verifying a collection, you must create matchers for all the elements of the collection.

Consider the following example:

Contract.make {
    request {
        method 'GET'
        url("/foo")
    }
    response {
        status OK()
        body(events: [[
                                 operation          : 'EXPORT',
                                 eventId            : '16f1ed75-0bcc-4f0d-a04d-3121798faf99',
                                 status             : 'OK'
                         ], [
                                 operation          : 'INPUT_PROCESSING',
                                 eventId            : '3bb4ac82-6652-462f-b6d1-75e424a0024a',
                                 status             : 'OK'
                         ]
                ]
        )
        bodyMatchers {
            jsonPath('$.events[0].operation', byRegex('.+'))
            jsonPath('$.events[0].eventId', byRegex('^([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})$'))
            jsonPath('$.events[0].status', byRegex('.+'))
        }
    }
}

The preceding code leads to creating the following test (the code block shows only the assertion section):

and:
	DocumentContext parsedJson = JsonPath.parse(response.body.asString())
	assertThatJson(parsedJson).array("['events']").contains("['eventId']").isEqualTo("16f1ed75-0bcc-4f0d-a04d-3121798faf99")
	assertThatJson(parsedJson).array("['events']").contains("['operation']").isEqualTo("EXPORT")
	assertThatJson(parsedJson).array("['events']").contains("['operation']").isEqualTo("INPUT_PROCESSING")
	assertThatJson(parsedJson).array("['events']").contains("['eventId']").isEqualTo("3bb4ac82-6652-462f-b6d1-75e424a0024a")
	assertThatJson(parsedJson).array("['events']").contains("['status']").isEqualTo("OK")
and:
	assertThat(parsedJson.read("\$.events[0].operation", String.class)).matches(".+")
	assertThat(parsedJson.read("\$.events[0].eventId", String.class)).matches("^([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\$")
	assertThat(parsedJson.read("\$.events[0].status", String.class)).matches(".+")

Note that the assertion is malformed. Only the first element of the array got asserted. To fix this, apply the assertion to the whole $.events collection and assert it with the byCommand(…​) method.