From d72524173d09cdefd8781d7e97b138c70869f88b Mon Sep 17 00:00:00 2001 From: Henry Jonathan Date: Fri, 8 Jun 2018 17:25:37 +0700 Subject: [PATCH] Contract for multipart/mixed endpoint backport to 1.2.x (#662) * apply cherry pick based on b965c49a7efee44a64c72f1c4420899cec0fa4d3 and fix conflict --- .gitignore | 1 + .../loan/LoanApplicationServiceTests.java | 45 ++++++++++++++++ .../com/example/fraud/TestController.java | 36 +++++++++++++ .../java/com/example/fraud/MultipartBase.java | 14 +++++ ...ouldWorkForMultipartWithContentType.groovy | 33 ++++++++++++ .../resources/contracts/multipart/test.json | 3 ++ .../contract/spec/internal/Common.groovy | 4 ++ .../spec/internal/NamedProperty.groovy | 23 +++++++- .../spec/internal/RegexPatterns.groovy | 16 +++++- .../spring-cloud-contract-converters/pom.xml | 5 ++ .../DslToWireMockClientConverterSpec.groovy | 4 ++ .../pom.xml | 4 +- .../WireMockRequestStubStrategy.groovy | 4 +- .../verifier/util/ContentUtils.groovy | 24 ++++++++- .../MockMvcMethodBodyBuilderSpec.groovy | 54 ++++++++++++++++++- 15 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 samples/standalone/dsl/http-server/src/main/java/com/example/fraud/TestController.java create mode 100644 samples/standalone/dsl/http-server/src/test/java/com/example/fraud/MultipartBase.java create mode 100644 samples/standalone/dsl/http-server/src/test/resources/contracts/multipart/shouldWorkForMultipartWithContentType.groovy create mode 100644 samples/standalone/dsl/http-server/src/test/resources/contracts/multipart/test.json diff --git a/.gitignore b/.gitignore index e92ba1f3f9..c816816adf 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ bin/ # Package Files # *.war *.ear +*.versionsBackup # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* diff --git a/samples/standalone/dsl/http-client/src/test/java/com/example/loan/LoanApplicationServiceTests.java b/samples/standalone/dsl/http-client/src/test/java/com/example/loan/LoanApplicationServiceTests.java index 97ff1f3e34..2daa188acc 100644 --- a/samples/standalone/dsl/http-client/src/test/java/com/example/loan/LoanApplicationServiceTests.java +++ b/samples/standalone/dsl/http-client/src/test/java/com/example/loan/LoanApplicationServiceTests.java @@ -6,6 +6,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner; +import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.junit4.SpringRunner; @@ -13,6 +17,9 @@ import com.example.loan.model.Client; import com.example.loan.model.LoanApplication; import com.example.loan.model.LoanApplicationResult; import com.example.loan.model.LoanApplicationStatus; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; import static org.assertj.core.api.Assertions.assertThat; @@ -79,4 +86,42 @@ public class LoanApplicationServiceTests { assertThat(cookies).isEqualTo("foo bar"); } + @Test + public void shouldSuccessfullyWorkWithMultipart() { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + + parameters.add("file1", new ByteArrayResource(("content1").getBytes()) { + @Override + public String getFilename() { + return "filename1"; + } + }); + parameters.add("file2", new ByteArrayResource(("content2").getBytes()) { + @Override + public String getFilename() { + return "filename2"; + } + }); + + HttpHeaders jsonHeader = new HttpHeaders(); + jsonHeader.set("Content-Type", "application/json"); + parameters.add("test", new HttpEntity<>(new ByteArrayResource(("{\n \"status\": \"test\"\n}").getBytes()) { + @Override + public String getFilename() { + return "filename3"; + } + }, jsonHeader)); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "multipart/form-data"); + headers.set("Accept", "text/plain"); + + String result = new RestTemplate().postForObject( + "http://localhost:6565/tests", + new HttpEntity>(parameters, headers), + String.class); + + assertThat(result).isEqualTo("{\"status\":\"ok\"}"); + } + } diff --git a/samples/standalone/dsl/http-server/src/main/java/com/example/fraud/TestController.java b/samples/standalone/dsl/http-server/src/main/java/com/example/fraud/TestController.java new file mode 100644 index 0000000000..4ca3826ef2 --- /dev/null +++ b/samples/standalone/dsl/http-server/src/main/java/com/example/fraud/TestController.java @@ -0,0 +1,36 @@ +package com.example.fraud; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +public class TestController { + + @PostMapping("/tests") + public Test createNew(@RequestPart MultipartFile file1, + @RequestPart MultipartFile file2, + @RequestPart Test test) { + return new Test("ok"); + } +} + +class Test { + private String status; + + public Test(String status) { + this.status = status; + } + + public Test() { + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} \ No newline at end of file diff --git a/samples/standalone/dsl/http-server/src/test/java/com/example/fraud/MultipartBase.java b/samples/standalone/dsl/http-server/src/test/java/com/example/fraud/MultipartBase.java new file mode 100644 index 0000000000..90a87c0636 --- /dev/null +++ b/samples/standalone/dsl/http-server/src/test/java/com/example/fraud/MultipartBase.java @@ -0,0 +1,14 @@ +package com.example.fraud; + +import com.example.fraud.TestController; +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import org.junit.Before; +import org.junit.runner.RunWith; + +public class MultipartBase { + + @Before + public void setUp() throws Exception { + RestAssuredMockMvc.standaloneSetup(new TestController()); + } +} \ No newline at end of file diff --git a/samples/standalone/dsl/http-server/src/test/resources/contracts/multipart/shouldWorkForMultipartWithContentType.groovy b/samples/standalone/dsl/http-server/src/test/resources/contracts/multipart/shouldWorkForMultipartWithContentType.groovy new file mode 100644 index 0000000000..d92995081f --- /dev/null +++ b/samples/standalone/dsl/http-server/src/test/resources/contracts/multipart/shouldWorkForMultipartWithContentType.groovy @@ -0,0 +1,33 @@ +import org.springframework.cloud.contract.spec.Contract + +Contract.make { + request { + method 'POST' + url '/tests' + multipart( + file1: named( + name: value(consumer(regex(nonEmpty())), producer('filename1')), + content: value(consumer(regex(nonEmpty())), producer('content1'))), + file2: named( + name: value(consumer(regex(nonEmpty())), producer('filename2')), + content: value(c(regex(nonEmpty())), producer('content2'))), + test : named( + name: value(consumer(regex(nonEmpty())), producer('filename3')), + content: value(c(regex(nonEmpty())), producer(file("test.json"))), + contentType: "application/json") + ) + + headers { + contentType('multipart/form-data') + } + } + response { + status 200 + body('''\ + {"status":"ok"} + ''') + headers { + contentType('application/json') + } + } +} diff --git a/samples/standalone/dsl/http-server/src/test/resources/contracts/multipart/test.json b/samples/standalone/dsl/http-server/src/test/resources/contracts/multipart/test.json new file mode 100644 index 0000000000..21a721f25a --- /dev/null +++ b/samples/standalone/dsl/http-server/src/test/resources/contracts/multipart/test.json @@ -0,0 +1,3 @@ +{ + "status": "test" +} \ No newline at end of file diff --git a/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/Common.groovy b/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/Common.groovy index 122d042ef0..2df6946abb 100644 --- a/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/Common.groovy +++ b/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/Common.groovy @@ -71,6 +71,10 @@ class Common { return new NamedProperty(name, value) } + NamedProperty named(DslProperty name, DslProperty value, DslProperty contentType) { + return new NamedProperty(name, value, contentType) + } + NamedProperty named(Map namedMap){ return new NamedProperty(namedMap) } diff --git a/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/NamedProperty.groovy b/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/NamedProperty.groovy index 2907acf2f5..6ffea29205 100644 --- a/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/NamedProperty.groovy +++ b/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/NamedProperty.groovy @@ -33,16 +33,37 @@ class NamedProperty { private static final String NAME = 'name' private static final String CONTENT = 'content' + private static final String CONTENT_TYPE = 'contentType' DslProperty name DslProperty value + DslProperty contentType NamedProperty(DslProperty name, DslProperty value) { this.name = name this.value = value + this.contentType = null + } + + NamedProperty(DslProperty name, DslProperty value, DslProperty contentType) { + this.name = name + this.value = value + this.contentType = contentType } NamedProperty(Map namedMap) { - this(namedMap?.get(NAME), namedMap?.get(CONTENT)) + this(asDslProperty(namedMap?.get(NAME)), + asDslProperty(namedMap?.get(CONTENT)), + asDslProperty(namedMap?.get(CONTENT_TYPE))) + } + + static DslProperty asDslProperty(Object o) { + if (o == null) { + return null + } + if (o instanceof DslProperty) { + return o + } + return new DslProperty(o) } } diff --git a/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/RegexPatterns.groovy b/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/RegexPatterns.groovy index 17be0c063a..c2ebc60754 100644 --- a/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/RegexPatterns.groovy +++ b/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/RegexPatterns.groovy @@ -25,6 +25,8 @@ import java.util.regex.Pattern /** * Contains most common regular expression patterns * + * @author Marcin Grzejszczak + * @author Tim Ysewyn * @since 1.0.0 */ @CompileStatic @@ -114,8 +116,18 @@ class RegexPatterns { return ".*--(.*)\r\nContent-Disposition: form-data; name=\"$name\"\r\n(Content-Type: .*\r\n)?(Content-Length: \\d+\r\n)?\r\n$value\r\n--\\1.*" } - static String multipartFile(Object name, Object filename, Object content) { - return ".*--(.*)\r\nContent-Disposition: form-data; name=\"$name\"; filename=\"$filename\"\r\n(Content-Type: .*\r\n)?(Content-Length: \\d+\r\n)?\r\n$content\r\n--\\1.*"; + static String multipartFile(Object name, Object filename, Object content, Object contentType) { + return ".*--(.*)\r\nContent-Disposition: form-data; name=\"$name\"; filename=\"$filename\"\r\n(Content-Type: ${toContentType(contentType)}\r\n)?(Content-Length: \\d+\r\n)?\r\n$content\r\n--\\1.*"; + } + + private static String toContentType(Object contentType) { + if (contentType == null) { + return '.*' + } + if (contentType instanceof Pattern) { + return contentType.pattern() + } + return contentType.toString() } } diff --git a/spring-cloud-contract-tools/spring-cloud-contract-converters/pom.xml b/spring-cloud-contract-tools/spring-cloud-contract-converters/pom.xml index 797fd30577..21345dd057 100644 --- a/spring-cloud-contract-tools/spring-cloud-contract-converters/pom.xml +++ b/spring-cloud-contract-tools/spring-cloud-contract-converters/pom.xml @@ -66,6 +66,11 @@ spring-boot-starter-web test + + io.rest-assured + rest-assured + test + diff --git a/spring-cloud-contract-tools/spring-cloud-contract-converters/src/test/groovy/org/springframework/cloud/contract/verifier/wiremock/DslToWireMockClientConverterSpec.groovy b/spring-cloud-contract-tools/spring-cloud-contract-converters/src/test/groovy/org/springframework/cloud/contract/verifier/wiremock/DslToWireMockClientConverterSpec.groovy index 2f1644ca0d..b15eb488b3 100755 --- a/spring-cloud-contract-tools/spring-cloud-contract-converters/src/test/groovy/org/springframework/cloud/contract/verifier/wiremock/DslToWireMockClientConverterSpec.groovy +++ b/spring-cloud-contract-tools/spring-cloud-contract-converters/src/test/groovy/org/springframework/cloud/contract/verifier/wiremock/DslToWireMockClientConverterSpec.groovy @@ -28,7 +28,11 @@ import org.springframework.cloud.contract.verifier.dsl.wiremock.WireMockStubMapp import org.springframework.cloud.contract.spec.Contract import org.springframework.cloud.contract.verifier.file.ContractMetadata import org.springframework.cloud.contract.verifier.util.ContractVerifierDslConverter +import org.springframework.core.io.ByteArrayResource +import org.springframework.http.HttpEntity import org.springframework.http.RequestEntity +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap import org.springframework.util.SocketUtils import spock.lang.Issue import spock.lang.Specification diff --git a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/pom.xml b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/pom.xml index 7817a7db57..3a3d5aba61 100644 --- a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/pom.xml +++ b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/pom.xml @@ -208,10 +208,10 @@ true true - ${project.basedir}/src/main/java,${project.basedir}/src/main/groovy + ${project.basedir}/src/main/java,${project.basedir}/src/main/groovy - ${project.basedir}/src/test/java,${project.basedir}/src/test/groovy + ${project.basedir}/src/test/java,${project.basedir}/src/test/groovy diff --git a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockRequestStubStrategy.groovy b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockRequestStubStrategy.groovy index c19f145576..0f0c83ffb7 100755 --- a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockRequestStubStrategy.groovy +++ b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockRequestStubStrategy.groovy @@ -132,7 +132,9 @@ class WireMockRequestStubStrategy extends BaseWireMockStubStrategy { if (request.multipart.clientValue instanceof Map) { List multipartPatterns = (request.multipart.clientValue as Map).collect { (it.value instanceof NamedProperty - ? WireMock.matching(RegexPatterns.multipartFile(it.key, (it.value as NamedProperty).name.clientValue, (it.value as NamedProperty).value.clientValue)) + ? WireMock.matching(RegexPatterns.multipartFile(it.key, (it.value as NamedProperty).name.clientValue, + (it.value as NamedProperty).value.clientValue, + (it.value as NamedProperty).contentType?.clientValue)) : WireMock.matching(RegexPatterns.multipartParam(it.key, it.value)) ) } multipartPatterns.each { diff --git a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/ContentUtils.groovy b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/ContentUtils.groovy index 140dced5b3..5b16fc20fd 100644 --- a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/ContentUtils.groovy +++ b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/ContentUtils.groovy @@ -388,11 +388,13 @@ class ContentUtils { } static String getGroovyMultipartFileParameterContent(String propertyName, NamedProperty propertyValue) { - return "'$propertyName', ${namedPropertyName(propertyValue, "'")}, ${namedPropertyValue(propertyValue, "'")}.bytes" + return "'$propertyName', ${namedPropertyName(propertyValue, "'")}, " + + "${namedPropertyValue(propertyValue, "'")}.bytes" + namedContentTypeNameIfPresent(propertyValue, "'") } static String getJavaMultipartFileParameterContent(String propertyName, NamedProperty propertyValue) { - return """"${escapeJava(propertyName)}", ${namedPropertyName(propertyValue, '"')}, ${namedPropertyValue(propertyValue, '"')}.getBytes()""" + return """"${escapeJava(propertyName)}", ${namedPropertyName(propertyValue, '"')}, """ + + """${javaNamedPropertyValue(propertyValue, '"')}${namedContentTypeNameIfPresent(propertyValue, '"')}""" } static String namedPropertyName(NamedProperty property, String quote) { @@ -400,9 +402,27 @@ class ContentUtils { property.name.serverValue.toString() : quote + escapeJava(property.name.serverValue.toString()) + quote } + static String namedContentTypeNameIfPresent(NamedProperty property, String quote) { + if (!property.contentType) { + return "" + } + String contentType = property.contentType.serverValue instanceof ExecutionProperty ? + property.contentType.serverValue.toString() : quote + escapeJava(property.contentType.serverValue.toString()) + quote + return ", " + contentType + } + static String namedPropertyValue(NamedProperty property, String quote) { return property.value.serverValue instanceof ExecutionProperty ? property.value.serverValue.toString() : quote + escapeJava(property.value.serverValue.toString()) + quote } + static String javaNamedPropertyValue(NamedProperty property, String quote) { + if (property.value.serverValue instanceof ExecutionProperty) { + return property.value.serverValue.toString() + } else if (property.value.serverValue instanceof byte[]) { + byte[] bytes = (byte[]) property.value.serverValue + return "new byte[] {" + bytes.collect { it }.join(", ") + "}" + } + return quote + escapeJava(property.value.serverValue.toString()) + quote + ".getBytes()" + } } diff --git a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/MockMvcMethodBodyBuilderSpec.groovy b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/MockMvcMethodBodyBuilderSpec.groovy index b06594888c..4a61e3c4bf 100644 --- a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/MockMvcMethodBodyBuilderSpec.groovy +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/builder/MockMvcMethodBodyBuilderSpec.groovy @@ -1249,7 +1249,9 @@ World.'''""" // name of the file name: $(c(regex(nonEmpty())), p('filename.csv')), // content of the file - content: $(c(regex(nonEmpty())), p('file content'))) + content: $(c(regex(nonEmpty())), p('file content')), + // content type for the part + contentType: $(c(regex(nonEmpty())), p('application/json'))) ) } response { @@ -1270,6 +1272,56 @@ World.'''""" SyntaxChecker.tryToCompile(methodBuilderName, blockBuilder.toString()) where: methodBuilderName | methodBuilder | requestStrings + "MockMvcSpockMethodBuilder" | { Contract dsl -> new MockMvcSpockMethodRequestProcessingBodyBuilder(dsl, properties) } | ['"Content-Type", "multipart/form-data;boundary=AaB03x"', + """.param('formParameter', '"formParameterValue"'""", + """.param('someBooleanParameter', 'true')""", + """.multiPart('file', 'filename.csv', 'file content'.bytes, 'application/json')"""] + "MockMvcJUnitMethodBuilder" | { Contract dsl -> new MockMvcJUnitMethodBodyBuilder(dsl, properties) } | ['"Content-Type", "multipart/form-data;boundary=AaB03x"', + '.param("formParameter", "\\"formParameterValue\\"")', + '.param("someBooleanParameter", "true")', + '.multiPart("file", "filename.csv", "file content".getBytes(), "application/json");'] + } + + @Issue('180') + def "should generate proper test code when having multipart parameters without content type with #methodBuilderName"() { + given: + org.springframework.cloud.contract.spec.Contract contractDsl = org.springframework.cloud.contract.spec.Contract.make { + request { + method "PUT" + url "/multipart" + headers { + contentType('multipart/form-data;boundary=AaB03x') + } + multipart( + // key (parameter name), value (parameter value) pair + formParameter: $(c(regex('".+"')), p('"formParameterValue"')), + someBooleanParameter: $(c(regex(anyBoolean())), p('true')), + // a named parameter (e.g. with `file` name) that represents file with + // `name` and `content`. You can also call `named("fileName", "fileContent")` + file: named( + // name of the file + name: $(c(regex(nonEmpty())), p('filename.csv')), + // content of the file + content: $(c(regex(nonEmpty())), p('file content'))) + ) + } + response { + status 200 + } + } + MethodBodyBuilder builder = methodBuilder(contractDsl) + BlockBuilder blockBuilder = new BlockBuilder(" ") + when: + builder.appendTo(blockBuilder) + def test = blockBuilder.toString() + then: + for (String requestString : requestStrings) { + assert test.contains(requestString) + } + and: + SyntaxChecker.tryToCompile(methodBuilderName, blockBuilder.toString()) + where: + methodBuilderName | methodBuilder | requestStrings "MockMvcSpockMethodBuilder" | { Contract dsl -> new MockMvcSpockMethodRequestProcessingBodyBuilder(dsl, properties) } | ['"Content-Type", "multipart/form-data;boundary=AaB03x"', """.param('formParameter', '"formParameterValue"'""", """.param('someBooleanParameter', 'true')""",