Skip to content

Commit 78bc52f

Browse files
committed
Merge branch '4.3.x'
2 parents 15cba8b + 226c205 commit 78bc52f

9 files changed

Lines changed: 240 additions & 9 deletions

File tree

spring-cloud-contract-verifier/pom.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@
154154
<groupId>com.github.jknack</groupId>
155155
<artifactId>handlebars</artifactId>
156156
</dependency>
157+
<!-- Leaving for backward-compatibility. We no longer rely on this library
158+
and neither should you. For more information check
159+
https://github.com/spring-cloud/spring-cloud-contract/issues/2129 -->
157160
<dependency>
158161
<groupId>commons-beanutils</groupId>
159162
<artifactId>commons-beanutils</artifactId>

spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BodyAssertionLineCreator.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ else if (value instanceof DslProperty) {
7070
return getResponseBodyPropertyComparisonString(singleContractMetadata, property,
7171
((DslProperty) value).getServerValue());
7272
}
73+
else if (value instanceof EscapedString) {
74+
return getResponseBodyPropertyComparisonString(property, (EscapedString) value);
75+
}
7376
return getResponseBodyPropertyComparisonString(property, value.toString());
7477
}
7578

@@ -94,6 +97,12 @@ private String getResponseBodyPropertyComparisonString(String property, String v
9497
return this.comparisonBuilder.assertThatUnescaped("responseBody" + property, value);
9598
}
9699

100+
private String getResponseBodyPropertyComparisonString(String property, EscapedString value) {
101+
String quoted = this.comparisonBuilder.bodyParser().quotedEscapedShortText(value.value());
102+
return this.comparisonBuilder.assertThat("responseBody" + property)
103+
+ this.comparisonBuilder.isEqualToUnquoted(quoted);
104+
}
105+
97106
/**
98107
* Builds the code that for the given {@code property} will match it to the given
99108
* regular expression {@code value}

spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/BodyParser.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,12 @@ else if (TEXT != contentType && FORM != contentType && DEFINED != contentType) {
136136
}
137137

138138
default String escape(String text) {
139-
return StringEscapeUtils.escapeJava(text);
139+
String escaped = StringEscapeUtils.escapeJava(text);
140+
return escaped.replace("\r", "\\r").replace("\n", "\\n");
140141
}
141142

142143
default String escapeForSimpleTextAssertion(String text) {
143-
return text;
144+
return escape(text);
144145
}
145146

146147
default String postProcessJsonPath(String jsonPath) {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright 2013-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.contract.verifier.builder;
18+
19+
final class EscapedString {
20+
21+
private final String value;
22+
23+
EscapedString(String value) {
24+
this.value = value;
25+
}
26+
27+
String value() {
28+
return this.value;
29+
}
30+
31+
}

spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/GenericTextBodyThen.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ class GenericTextBodyThen implements Then {
4646
public MethodVisitor<Then> apply(SingleContractMetadata metadata) {
4747
Object convertedResponseBody = this.bodyParser.convertResponseBody(metadata);
4848
if (convertedResponseBody instanceof String) {
49-
convertedResponseBody = this.bodyParser.escapeForSimpleTextAssertion(convertedResponseBody.toString());
49+
String escaped = this.bodyParser.escapeForSimpleTextAssertion(convertedResponseBody.toString());
50+
convertedResponseBody = new EscapedString(escaped);
5051
}
5152
simpleTextResponseBodyCheck(metadata, convertedResponseBody);
5253
return this;

spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/GroovyBodyParser.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ default String postProcessJsonPath(String jsonPath) {
3939

4040
@Override
4141
default String escape(String text) {
42-
return text.replaceAll("\\n", "\\\\n");
42+
String escaped = text.replace("\r", "\\r").replace("\n", "\\n");
43+
return escaped.replaceAll("\\n", "\\\\n");
4344
}
4445

4546
@Override

spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/builder/JsonBodyVerificationBuilder.java

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
package org.springframework.cloud.contract.verifier.builder;
1818

1919
import java.math.BigDecimal;
20+
import java.lang.reflect.Array;
21+
import java.util.ArrayList;
2022
import java.util.List;
2123
import java.util.Map;
2224
import java.util.Optional;
@@ -26,8 +28,7 @@
2628
import com.jayway.jsonpath.JsonPath;
2729
import com.jayway.jsonpath.PathNotFoundException;
2830
import groovy.json.JsonOutput;
29-
import org.apache.commons.beanutils.PropertyUtilsBean;
30-
31+
import org.springframework.beans.BeanWrapperImpl;
3132
import org.springframework.cloud.contract.spec.Contract;
3233
import org.springframework.cloud.contract.spec.ContractTemplate;
3334
import org.springframework.cloud.contract.spec.internal.BodyMatcher;
@@ -298,7 +299,7 @@ private static Object retrieveObjectByPath(Object body, String path) {
298299
+ contractTemplate.escapedClosingTemplate();
299300
}
300301
try {
301-
Object result = new PropertyUtilsBean().getProperty(templateModel, justEntry);
302+
Object result = resolveTemplateModelEntry(templateModel, justEntry);
302303
// Path from the Test model is an object and we'd like to return its
303304
// String representation
304305
if (FROM_REQUEST_PATH.equals(justEntry)) {
@@ -314,6 +315,105 @@ private static Object retrieveObjectByPath(Object body, String path) {
314315
};
315316
}
316317

318+
private Object resolveTemplateModelEntry(TestSideRequestTemplateModel templateModel, String propertyPath) {
319+
Object current = templateModel;
320+
for (String token : tokenizePropertyPath(propertyPath)) {
321+
current = resolveNextToken(current, token);
322+
}
323+
return current;
324+
}
325+
326+
private Object resolveNextToken(Object current, String token) {
327+
if (current == null) {
328+
throw new IllegalStateException("Unable to resolve property for null value");
329+
}
330+
if (current instanceof Map) {
331+
Map<?, ?> map = (Map<?, ?>) current;
332+
String key = unquote(token);
333+
if (!map.containsKey(key)) {
334+
throw new IllegalStateException("Missing map key [" + key + "]");
335+
}
336+
return map.get(key);
337+
}
338+
if (current instanceof List) {
339+
int index = parseIndex(token);
340+
List<?> list = (List<?>) current;
341+
if (index < 0 || index >= list.size()) {
342+
throw new IllegalStateException("Index [" + index + "] out of bounds");
343+
}
344+
return list.get(index);
345+
}
346+
if (current.getClass().isArray()) {
347+
int index = parseIndex(token);
348+
int length = Array.getLength(current);
349+
if (index < 0 || index >= length) {
350+
throw new IllegalStateException("Index [" + index + "] out of bounds");
351+
}
352+
return Array.get(current, index);
353+
}
354+
BeanWrapperImpl wrapper = new BeanWrapperImpl(current);
355+
if (!wrapper.isReadableProperty(token)) {
356+
throw new IllegalStateException("No readable property [" + token + "]");
357+
}
358+
return wrapper.getPropertyValue(token);
359+
}
360+
361+
private int parseIndex(String token) {
362+
try {
363+
return Integer.parseInt(token);
364+
}
365+
catch (NumberFormatException ex) {
366+
throw new IllegalStateException("Invalid index token [" + token + "]", ex);
367+
}
368+
}
369+
370+
private List<String> tokenizePropertyPath(String propertyPath) {
371+
List<String> tokens = new ArrayList<>();
372+
String[] segments = propertyPath.split("\\.");
373+
for (String segment : segments) {
374+
addTokens(segment, tokens);
375+
}
376+
return tokens;
377+
}
378+
379+
private void addTokens(String segment, List<String> tokens) {
380+
int index = 0;
381+
while (index < segment.length()) {
382+
int bracketStart = segment.indexOf('[', index);
383+
if (bracketStart == -1) {
384+
String token = segment.substring(index);
385+
if (!token.isEmpty()) {
386+
tokens.add(token);
387+
}
388+
return;
389+
}
390+
String before = segment.substring(index, bracketStart);
391+
if (!before.isEmpty()) {
392+
tokens.add(before);
393+
}
394+
int bracketEnd = segment.indexOf(']', bracketStart);
395+
if (bracketEnd == -1) {
396+
String remainder = segment.substring(bracketStart + 1);
397+
if (!remainder.isEmpty()) {
398+
tokens.add(remainder);
399+
}
400+
return;
401+
}
402+
String inside = segment.substring(bracketStart + 1, bracketEnd);
403+
if (!inside.isEmpty()) {
404+
tokens.add(inside);
405+
}
406+
index = bracketEnd + 1;
407+
}
408+
}
409+
410+
private String unquote(String value) {
411+
if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) {
412+
return value.substring(1, value.length() - 1);
413+
}
414+
return value;
415+
}
416+
317417
private static String minus(CharSequence self, Object target) {
318418
String s = self.toString();
319419
String text = target.toString();

spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/converter/YamlToContracts.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ private void handleQueryParameters(YamlContract yamlContract, Url url) {
232232

233233
private void mapRequestHeaders(YamlContract.Request yamlContractRequest, Request dslContractRequest) {
234234
Map<String, Object> yamlContractRequestHeaders = yamlContractRequest.headers;
235-
if (MapUtils.isNotEmpty(yamlContractRequestHeaders)) {
235+
if (yamlContractRequestHeaders != null && !yamlContractRequestHeaders.isEmpty()) {
236236
dslContractRequest.headers((headers) -> yamlContractRequestHeaders.forEach((key, value) -> {
237237
List<YamlContract.KeyValueMatcher> matchers = yamlContractRequest.matchers.headers.stream()
238238
.filter((header) -> header.key.equals(key))
@@ -256,7 +256,7 @@ private void mapRequestHeaders(YamlContract.Request yamlContractRequest, Request
256256

257257
private void mapRequestCookies(YamlContract.Request yamlContractRequest, Request dslContractRequest) {
258258
Map<String, Object> yamlContractRequestCookies = yamlContractRequest.cookies;
259-
if (MapUtils.isNotEmpty(yamlContractRequestCookies)) {
259+
if (yamlContractRequestCookies != null && !yamlContractRequestCookies.isEmpty()) {
260260
dslContractRequest.cookies((cookies) -> yamlContractRequestCookies.forEach((key, value) -> {
261261
YamlContract.KeyValueMatcher matcher = yamlContractRequest.matchers.cookies.stream()
262262
.filter(cookie -> cookie.key.equals(key))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package org.springframework.cloud.contract.verifier.builder;
2+
3+
import java.util.HashMap;
4+
import java.util.Map;
5+
import java.util.Optional;
6+
import java.util.function.Function;
7+
8+
import org.junit.Test;
9+
10+
import org.springframework.cloud.contract.spec.Contract;
11+
import org.springframework.cloud.contract.spec.internal.BodyMatchers;
12+
import org.springframework.cloud.contract.verifier.template.HandlebarsTemplateProcessor;
13+
14+
import static org.assertj.core.api.Assertions.assertThat;
15+
16+
class JsonBodyVerificationBuilderTest {
17+
18+
@Test
19+
public void should_resolve_request_template_values_when_body_present() {
20+
// given
21+
Contract contract = contractWithRequest();
22+
JsonBodyVerificationBuilder builder = jsonBuilder(contract);
23+
Map<String, Object> responseBody = new HashMap<>();
24+
responseBody.put("auth0", "{{{ request.headers.Authorization.0 }}}");
25+
responseBody.put("auth1", "{{{ request.headers.Authorization.[1] }}}");
26+
responseBody.put("param", "{{{ request.query.foo.1 }}}");
27+
responseBody.put("path", "{{{ request.path.1 }}}");
28+
29+
// when
30+
Object converted = builder.addJsonResponseBodyCheck(new BlockBuilder(" "), responseBody, new BodyMatchers(),
31+
"\"{}\"", true);
32+
33+
// then
34+
assertThat(converted).isInstanceOf(Map.class);
35+
Map<?, ?> convertedMap = (Map<?, ?>) converted;
36+
assertThat(convertedMap.get("auth0")).isEqualTo("alpha");
37+
assertThat(convertedMap.get("auth1")).isEqualTo("beta");
38+
assertThat(convertedMap.get("param")).isEqualTo("baz");
39+
assertThat(convertedMap.get("path")).isEqualTo("12");
40+
}
41+
42+
@Test
43+
public void should_keep_template_entry_when_property_missing() {
44+
// given
45+
Contract contract = contractWithRequest();
46+
JsonBodyVerificationBuilder builder = jsonBuilder(contract);
47+
Map<String, Object> responseBody = new HashMap<>();
48+
String templateEntry = "{{{ request.headers.Missing.0 }}}";
49+
responseBody.put("missing", templateEntry);
50+
51+
// when
52+
Object converted = builder.addJsonResponseBodyCheck(new BlockBuilder(" "), responseBody, new BodyMatchers(),
53+
"\"{}\"", true);
54+
55+
// then
56+
Map<?, ?> convertedMap = (Map<?, ?>) converted;
57+
assertThat(convertedMap.get("missing")).isEqualTo(templateEntry);
58+
}
59+
60+
private JsonBodyVerificationBuilder jsonBuilder(Contract contract) {
61+
HandlebarsTemplateProcessor templateProcessor = new HandlebarsTemplateProcessor();
62+
return new JsonBodyVerificationBuilder(false, templateProcessor, templateProcessor, contract, Optional.empty(),
63+
Function.identity());
64+
}
65+
66+
private Contract contractWithRequest() {
67+
Contract contract = new Contract();
68+
contract.request(request -> {
69+
request.method("GET");
70+
request.url("/users/12", url -> url.queryParameters(query -> {
71+
query.parameter("foo", "bar");
72+
query.parameter("foo", "baz");
73+
}));
74+
request.headers(headers -> {
75+
headers.header("Authorization", "alpha");
76+
headers.header("Authorization", "beta");
77+
});
78+
Map<String, Object> requestBody = new HashMap<>();
79+
requestBody.put("key", "value");
80+
request.body(requestBody);
81+
});
82+
return contract;
83+
}
84+
85+
}

0 commit comments

Comments
 (0)