From 01f4ad76be8ba5fc8de3ea1f458ecd3892748aea Mon Sep 17 00:00:00 2001 From: Marcin Grzejszczak Date: Fri, 23 Sep 2016 10:16:51 +0200 Subject: [PATCH] Consumer Contracts (#83) With this functionality you can have one centralized repository containing all contracts. This repo will have to produce a JAR containing all contracts. The layout of the repository can be arbitrary but some sensible defaults are assumed. The producer will be able to then download that JAR and produce tests and stubs from it. fixes #38 --- README.adoc | 95 +++++++ .../main/asciidoc/verifier/introduction.adoc | 100 +++++++- docs/src/main/asciidoc/verifier/rest.adoc | 18 ++ pom.xml | 8 - .../contracts/.mvn/wrapper/maven-wrapper.jar | Bin 0 -> 49519 bytes .../.mvn/wrapper/maven-wrapper.properties | 1 + .../example/server/client1/expectation.groovy | 67 +++++ .../example/server/client2/expectation.groovy | 67 +++++ .../example/server/client3/expectation.groovy | 67 +++++ .../contracts/com/example/server/pom.xml | 107 ++++++++ samples/standalone/contracts/mvnw | 234 ++++++++++++++++++ samples/standalone/contracts/mvnw.cmd | 145 +++++++++++ samples/standalone/contracts/pom.xml | 41 +++ .../contracts/src/assembly/contracts.xml | 23 ++ spring-cloud-contract-dependencies/pom.xml | 8 + .../stubrunner/ContractDownloader.java | 91 +++++++ .../stubrunner/StubConfiguration.java | 13 +- .../stubrunner/ContractDownloaderSpec.groovy | 41 +++ .../spring-cloud-contract-converters/pom.xml | 4 + .../wiremock/RecursiveFilesConverter.groovy | 3 +- .../plugin/ContractVerifierExtension.groovy | 127 ++++++++++ .../plugin/ExtensionToProperties.groovy | 31 +++ .../plugin/GenerateServerTestsTask.groovy | 6 +- ...erateWireMockClientStubsFromDslTask.groovy | 7 +- .../plugin/GradleContractsDownloader.groovy | 82 ++++++ ...ngCloudContractVerifierGradlePlugin.groovy | 40 +-- .../ContractVerifierIntegrationSpec.groovy | 2 +- .../plugin/ContractVerifierSpec.groovy | 3 +- .../sampleJerseyProject/build.gradle | 42 ++-- .../shouldMarkClientAsFraud.groovy | 48 ---- .../shouldMarkClientAsNotFraud.groovy | 49 ---- .../jersey-contracts-0.0.1-SNAPSHOT.jar | Bin 0 -> 3628 bytes .../jersey-contracts-0.0.1-SNAPSHOT.pom | 25 ++ .../0.0.1-SNAPSHOT/maven-metadata-local.xml | 24 ++ .../jersey-contracts/maven-metadata.xml | 28 +++ .../contract/maven/verifier/ConvertMojo.java | 54 +++- .../maven/verifier/GenerateStubsMojo.java | 3 + .../maven/verifier/GenerateTestsMojo.java | 49 +++- .../verifier/MavenContractsDownloader.java | 102 ++++++++ .../maven/verifier/PluginUnitTest.java | 27 ++ .../basic-remote-contracts/pom-with-repo.xml | 46 ++++ .../projects/basic-remote-contracts/pom.xml | 43 ++++ .../projects/complex-remote-contracts/pom.xml | 46 ++++ .../src/test/resources/logback.xml | 25 ++ .../contracts-0.0.1-SNAPSHOT.jar | Bin 0 -> 2995 bytes .../contracts-0.0.1-SNAPSHOT.pom | 25 ++ .../0.0.1-SNAPSHOT/maven-metadata-local.xml | 24 ++ .../com/example/contracts/maven-metadata.xml | 28 +++ .../ContractVerifierConfigProperties.groovy | 7 + .../verifier/file/ContractFileScanner.groovy | 37 ++- 50 files changed, 1995 insertions(+), 168 deletions(-) create mode 100644 samples/standalone/contracts/.mvn/wrapper/maven-wrapper.jar create mode 100644 samples/standalone/contracts/.mvn/wrapper/maven-wrapper.properties create mode 100644 samples/standalone/contracts/com/example/server/client1/expectation.groovy create mode 100644 samples/standalone/contracts/com/example/server/client2/expectation.groovy create mode 100644 samples/standalone/contracts/com/example/server/client3/expectation.groovy create mode 100644 samples/standalone/contracts/com/example/server/pom.xml create mode 100755 samples/standalone/contracts/mvnw create mode 100644 samples/standalone/contracts/mvnw.cmd create mode 100644 samples/standalone/contracts/pom.xml create mode 100644 samples/standalone/contracts/src/assembly/contracts.xml create mode 100644 spring-cloud-contract-stub-runner/src/main/java/org/springframework/cloud/contract/stubrunner/ContractDownloader.java create mode 100644 spring-cloud-contract-stub-runner/src/test/groovy/org/springframework/cloud/contract/stubrunner/ContractDownloaderSpec.groovy create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/ContractVerifierExtension.groovy create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/ExtensionToProperties.groovy create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/GradleContractsDownloader.groovy delete mode 100644 spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/fraudDetectionService/mappings/fraudDetectionService/shouldMarkClientAsFraud.groovy delete mode 100644 spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/fraudDetectionService/mappings/fraudDetectionService/shouldMarkClientAsNotFraud.groovy create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/m2repo/repository/com/example/jersey-contracts/0.0.1-SNAPSHOT/jersey-contracts-0.0.1-SNAPSHOT.jar create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/m2repo/repository/com/example/jersey-contracts/0.0.1-SNAPSHOT/jersey-contracts-0.0.1-SNAPSHOT.pom create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/m2repo/repository/com/example/jersey-contracts/0.0.1-SNAPSHOT/maven-metadata-local.xml create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/m2repo/repository/com/example/jersey-contracts/maven-metadata.xml create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/MavenContractsDownloader.java create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/projects/basic-remote-contracts/pom-with-repo.xml create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/projects/basic-remote-contracts/pom.xml create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/projects/complex-remote-contracts/pom.xml create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/logback.xml create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/m2repo/repository/com/example/contracts/0.0.1-SNAPSHOT/contracts-0.0.1-SNAPSHOT.jar create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/m2repo/repository/com/example/contracts/0.0.1-SNAPSHOT/contracts-0.0.1-SNAPSHOT.pom create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/m2repo/repository/com/example/contracts/0.0.1-SNAPSHOT/maven-metadata-local.xml create mode 100644 spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/m2repo/repository/com/example/contracts/maven-metadata.xml diff --git a/README.adoc b/README.adoc index 59a9673b21..4d492e96b2 100644 --- a/README.adoc +++ b/README.adoc @@ -414,6 +414,9 @@ going through the process. CDC is all about communication. The https://github.com/spring-cloud/spring-cloud-contract/tree/master/samples/standalone/dsl/http-server[server side code is available here] and https://github.com/spring-cloud/spring-cloud-contract/tree/master/samples/standalone/dsl/http-client[the client side code here]. +TIP: In this case the ownership of the contracts lays on the producer side. It means that physically +all the contract are present in the producer's repository + ===== Technical note If using the *SNAPSHOT* / *Milestone* / *Release Candidate* versions please add the following section to your @@ -1231,6 +1234,98 @@ Example of tests using production version of stubs You can pass those values also via properties from your deployment pipeline. +===== Common repo with contracts + +Another way of storing contracts other than having them with the producer is keeping them in a common place. +It can be related to security issues where the consumers can't clone the producer's code. Also if you keep +contracts in a single place then you, as a producer, will know how many consumers you have and which +consumer will you break with your local changes. + +* Repo structure * + +Let's assume that we have a producer with coordinates `com.example:server` and 3 consumers: `client1`, +`client2`, `client3`. Then in the repository with common contracts you would have the following setup +(which you can checkout https://github.com/spring-cloud/spring-cloud-contract/tree/master/samples/standalone/contracts[here]: + +[source,bash,indent=0] +---- +├── com +│   └── example +│   └── server +│   ├── client1 +│   │   └── expectation.groovy +│   ├── client2 +│   │   └── expectation.groovy +│   ├── client3 +│   │   └── expectation.groovy +│   └── pom.xml +├── mvnw +├── mvnw.cmd +├── pom.xml +└── src + └── assembly + └── contracts.xml +---- + +As you can see the under the slash-delimited groupid `/` artifact id folder (`com/example/server`) you have +expectations of the 3 consumers (`client1`, `client2` and `client3`). Expectations are the standard Groovy DSL +contract files as described throughout this documentation. This repository has to produce a JAR file that maps +one to one to the contents of the repo. + +Example of a `pom.xml` inside the `server` folder. + +[source,xml,indent=0] +---- +Unresolved directive in verifier/introduction.adoc - include::https://raw.githubusercontent.com/spring-cloud/spring-cloud-contract/master/samples/standalone/contracts/com/example/server/pom.xml[indent=0] + +As you can see there are no dependencies other than the Spring Cloud Contract Verifier Maven plugin. +Those poms are necessary for the consumer side to run `mvn clean install -DskipTests` to locally install + stubs of the producer project. + +The `pom.xml` in the root folder can look like this: + +[source,xml,indent=0] +---- +Unresolved directive in verifier/introduction.adoc - include::https://raw.githubusercontent.com/spring-cloud/spring-cloud-contract/master/samples/standalone/contracts/pom.xml[indent=0] + +It's using the assembly plugin in order to build the JAR with all the contracts. Example of such setup is here: + +[source,xml,indent=0] +---- +Unresolved directive in verifier/introduction.adoc - include::https://raw.githubusercontent.com/spring-cloud/spring-cloud-contract/master/samples/standalone/contracts/src/assembly/contracts.xml[indent=0] + +*Workflow* + +The workflow would look similar to the one presented in the `Step by step guide to CDC`. The only difference + is that the producer doesn't own the contracts anymore. So the consumer and the producer have to work on + common contracts in a common repository. + +_Consumer_ + +When the *consumer* wants to work on the contracts offline, instead of cloning the producer code, the +consumer team clones the common repository, goes to the required producer's folder (e.g. `com/example/server`) +and runs `mvn clean install -DskipTests` to install locally the stubs converted from the contracts. + +TIP: You need to have http://maven.apache.org/download.cgi[Maven installed locally] + +_Producer_ + +As a *producer* it's enough to alter the Spring Cloud Contract Verifier to provide the URL and the dependency +of the JAR containing the contracts: + +[source,xml,indent=0] +---- +Unresolved directive in verifier/introduction.adoc - include::https://raw.githubusercontent.com/spring-cloud/spring-cloud-contract/master/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/projects/basic-remote-contracts/pom-with-repo.xml[tags=remote_config,indent=0] +---- + +With this setup the JAR with groupid `com.example.standalone` and artifactid `contracts` will be downloaded +from `http://link/to/your/nexus/or/artifactory/or/sth`. It will be then unpacked in a local temporary folder +and contracts present under the `com/example/server` will be picked as the ones used to generate the +tests and the stubs. Due to this convention the producer team will know which consumer teams will be broken +when some incompatible changes are done. + +The rest of the flow looks the same. + === Links Here you can find interesting links related to Spring Cloud Contract Verifier: diff --git a/docs/src/main/asciidoc/verifier/introduction.adoc b/docs/src/main/asciidoc/verifier/introduction.adoc index 4900d9d157..7a299bbeaf 100644 --- a/docs/src/main/asciidoc/verifier/introduction.adoc +++ b/docs/src/main/asciidoc/verifier/introduction.adoc @@ -120,6 +120,9 @@ going through the process. CDC is all about communication. The https://github.com/spring-cloud/spring-cloud-contract/tree/master/samples/standalone/dsl/http-server[server side code is available here] and https://github.com/spring-cloud/spring-cloud-contract/tree/master/samples/standalone/dsl/http-client[the client side code here]. +TIP: In this case the ownership of the contracts lays on the producer side. It means that physically +all the contract are present in the producer's repository + ===== Technical note If using the *SNAPSHOT* / *Milestone* / *Release Candidate* versions please add the following section to your @@ -720,4 +723,99 @@ Example of tests using production version of stubs @AutoConfigureStubRunner(ids = {"com.example:http-server-dsl:+:prod-stubs:8080"}) ---- -You can pass those values also via properties from your deployment pipeline. \ No newline at end of file +You can pass those values also via properties from your deployment pipeline. + +===== Common repo with contracts + +Another way of storing contracts other than having them with the producer is keeping them in a common place. +It can be related to security issues where the consumers can't clone the producer's code. Also if you keep +contracts in a single place then you, as a producer, will know how many consumers you have and which +consumer will you break with your local changes. + +* Repo structure * + +Let's assume that we have a producer with coordinates `com.example:server` and 3 consumers: `client1`, +`client2`, `client3`. Then in the repository with common contracts you would have the following setup +(which you can checkout https://github.com/spring-cloud/spring-cloud-contract/tree/master/samples/standalone/contracts[here]: + +[source,bash,indent=0] +---- +├── com +│   └── example +│   └── server +│   ├── client1 +│   │   └── expectation.groovy +│   ├── client2 +│   │   └── expectation.groovy +│   ├── client3 +│   │   └── expectation.groovy +│   └── pom.xml +├── mvnw +├── mvnw.cmd +├── pom.xml +└── src + └── assembly + └── contracts.xml +---- + +As you can see the under the slash-delimited groupid `/` artifact id folder (`com/example/server`) you have +expectations of the 3 consumers (`client1`, `client2` and `client3`). Expectations are the standard Groovy DSL +contract files as described throughout this documentation. This repository has to produce a JAR file that maps +one to one to the contents of the repo. + +Example of a `pom.xml` inside the `server` folder. + +[source,xml,indent=0] +---- +include::{introduction_url}/samples/standalone/contracts/com/example/server/pom.xml[indent=0] +---- + +As you can see there are no dependencies other than the Spring Cloud Contract Verifier Maven plugin. +Those poms are necessary for the consumer side to run `mvn clean install -DskipTests` to locally install + stubs of the producer project. + +The `pom.xml` in the root folder can look like this: + +[source,xml,indent=0] +---- +include::{introduction_url}/samples/standalone/contracts/pom.xml[indent=0] +---- + +It's using the assembly plugin in order to build the JAR with all the contracts. Example of such setup is here: + +[source,xml,indent=0] +---- +include::{introduction_url}/samples/standalone/contracts/src/assembly/contracts.xml[indent=0] +---- + +*Workflow* + +The workflow would look similar to the one presented in the `Step by step guide to CDC`. The only difference + is that the producer doesn't own the contracts anymore. So the consumer and the producer have to work on + common contracts in a common repository. + +_Consumer_ + +When the *consumer* wants to work on the contracts offline, instead of cloning the producer code, the +consumer team clones the common repository, goes to the required producer's folder (e.g. `com/example/server`) +and runs `mvn clean install -DskipTests` to install locally the stubs converted from the contracts. + +TIP: You need to have http://maven.apache.org/download.cgi[Maven installed locally] + +_Producer_ + +As a *producer* it's enough to alter the Spring Cloud Contract Verifier to provide the URL and the dependency +of the JAR containing the contracts: + +[source,xml,indent=0] +---- +include::{introduction_url}/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/projects/basic-remote-contracts/pom-with-repo.xml[tags=remote_config,indent=0] +---- + +With this setup the JAR with groupid `com.example.standalone` and artifactid `contracts` will be downloaded +from `http://link/to/your/nexus/or/artifactory/or/sth`. It will be then unpacked in a local temporary folder +and contracts present under the `com/example/server` will be picked as the ones used to generate the +tests and the stubs. Due to this convention the producer team will know which consumer teams will be broken +when some incompatible changes are done. + +The rest of the flow looks the same. \ No newline at end of file diff --git a/docs/src/main/asciidoc/verifier/rest.adoc b/docs/src/main/asciidoc/verifier/rest.adoc index ad6f94cffc..84c8692553 100644 --- a/docs/src/main/asciidoc/verifier/rest.adoc +++ b/docs/src/main/asciidoc/verifier/rest.adoc @@ -129,6 +129,11 @@ contracts { contractsDslDir = "${project.rootDir}/src/test/resources/contracts" basePackageForTests = 'org.springframework.cloud.verifier.tests' stubsOutputDir = project.file("${project.buildDir}/stubs") + + // the following properties are used when you want to provide where the JAR with contract lays + contractDependency = new org.springframework.cloud.contract.verifier.plugin.ContractVerifierExtension.Dependency() + contractsPath = '' + contractsWorkOffline = false } tasks.create(type: Jar, name: 'verifierStubsJar', dependsOn: 'generateWireMockClientStubs') { @@ -185,6 +190,12 @@ contracts { - **stubsOutputDir** - dir where the generated WireMock stubs from Groovy DSL should be placed - **targetFramework** - the target test framework to be used; currently Spock and JUnit are supported with JUnit being the default framework +The following properties are used when you want to provide where the JAR with contract lays + + - **contractDependency** - the Dependency that provides `groupid:artifactid:version:classifier` coordinates. You can use the `contractDependency` closure to set it up + - **contractsPath** - if contract deps are downloaded will default to `groupid/artifactid` where `groupid` will be slash separated. Otherwise will scan contracts under provided directory + - **contractsWorkOffline** - in order not to download the dependencies each time you can download them once and work offline afterwards (reuse local Maven repo) + ====== Base class for tests When using Spring Cloud Contract Verifier in default MockMvc you need to create a base specification for all generated acceptance tests. In this class you need to point to endpoint which should be verified. @@ -331,6 +342,13 @@ To change default configuration just add `configuration` section to plugin defin - **contractsDir** - directory containing contracts written using the GroovyDSL. By default `/src/test/resources/contracts`. - **testFramework** - the target test framework to be used; currently Spock and JUnit are supported with JUnit being the default framework +If you want to download your contract definitions from a Maven repository you can use + + - **contractsRepositoryUrl** - URL to a repo with the artifacts with contracts, if not provided should use the current Maven ones + - **contractDependency** - the contract dependency that contains all the packaged contracts + - **contractsPath** - path to concrete contracts in the JAR with packaged contracts. Defaults to `groupid/artifactid` where `gropuid` is slash separated. + - **contractsWorkOffline** - if the dependencies should be downloaded or local Maven only should be reused + For complete information take a look at https://cloud.spring.io/spring-cloud-contract/spring-cloud-contract-maven-plugin/plugin-info.html[Plugin Documentation] ====== Base class for tests diff --git a/pom.xml b/pom.xml index 883984b6ae..2f5c65b4c2 100644 --- a/pom.xml +++ b/pom.xml @@ -32,7 +32,6 @@ Brooklyn.BUILD-SNAPSHOT 1.2.0.BUILD-SNAPSHOT 1.1.0.BUILD-SNAPSHOT - 1.1.3.BUILD-SNAPSHOT @@ -147,13 +146,6 @@ pom import - - org.springframework.cloud - spring-cloud-commons-dependencies - ${spring-cloud-commons.version} - pom - import - diff --git a/samples/standalone/contracts/.mvn/wrapper/maven-wrapper.jar b/samples/standalone/contracts/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..c6feb8bb6f76f2553e266ff8bf8867105154237e GIT binary patch literal 49519 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%0A$pwhjXzyA0ZzErH{1@M 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 literal 0 HcmV?d00001 diff --git a/samples/standalone/contracts/.mvn/wrapper/maven-wrapper.properties b/samples/standalone/contracts/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000..6637cedb28 --- /dev/null +++ b/samples/standalone/contracts/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.3.9/apache-maven-3.3.9-bin.zip \ No newline at end of file diff --git a/samples/standalone/contracts/com/example/server/client1/expectation.groovy b/samples/standalone/contracts/com/example/server/client1/expectation.groovy new file mode 100644 index 0000000000..47b9ba6662 --- /dev/null +++ b/samples/standalone/contracts/com/example/server/client1/expectation.groovy @@ -0,0 +1,67 @@ +package contracts + +org.springframework.cloud.contract.spec.Contract.make { + request { // (1) + method 'PUT' // (2) + url '/fraudcheck1' // (3) + body([ // (4) + clientId: value(consumer(regex('[0-9]{10}'))), + loanAmount: 99999 + ]) + headers { // (5) + header('Content-Type', 'application/vnd.fraud.v1+json') + } + } + response { // (6) + status 200 // (7) + body([ // (8) + fraudCheckStatus: "FRAUD", + rejectionReason: "Amount too high" + ]) + headers { // (9) + header('Content-Type': value( + producer(regex('application/vnd.fraud.v1.json.*')), + consumer('application/vnd.fraud.v1+json')) + ) + } + } +} + +/* +Since we don't want to force on the user to hardcode values of fields that are dynamic +(timestamps, database ids etc.), one can provide parametrize those entries by using the +`value(consumer(...), producer(...))` method. That way what's present in the `consumer` +section will end up in the produced stub. What's there in the `producer` will end up in the +autogenerated test. If you provide only the regular expression side without the concrete +value then Spring Cloud Contract will generate one for you. + +From the Consumer perspective, when shooting a request in the integration test: + +(1) - If the consumer sends a request +(2) - With the "PUT" method +(3) - to the URL "/fraudcheck" +(4) - with the JSON body that + * has a field `clientId` that matches a regular expression `[0-9]{10}` + * has a field `loanAmount` that is equal to `99999` +(5) - with header `Content-Type` equal to `application/vnd.fraud.v1+json` +(6) - then the response will be sent with +(7) - status equal `200` +(8) - and JSON body equal to + { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" } +(9) - with header `Content-Type` equal to `application/vnd.fraud.v1+json` + +From the Producer perspective, in the autogenerated producer-side test: + +(1) - A request will be sent to the producer +(2) - With the "PUT" method +(3) - to the URL "/fraudcheck" +(4) - with the JSON body that + * has a field `clientId` that will have a generated value that matches a regular expression `[0-9]{10}` + * has a field `loanAmount` that is equal to `99999` +(5) - with header `Content-Type` equal to `application/vnd.fraud.v1+json` +(6) - then the test will assert if the response has been sent with +(7) - status equal `200` +(8) - and JSON body equal to + { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" } +(9) - with header `Content-Type` matching `application/vnd.fraud.v1+json.*` + */ \ No newline at end of file diff --git a/samples/standalone/contracts/com/example/server/client2/expectation.groovy b/samples/standalone/contracts/com/example/server/client2/expectation.groovy new file mode 100644 index 0000000000..c8eb2adff4 --- /dev/null +++ b/samples/standalone/contracts/com/example/server/client2/expectation.groovy @@ -0,0 +1,67 @@ +package contracts + +org.springframework.cloud.contract.spec.Contract.make { + request { // (1) + method 'PUT' // (2) + url '/fraudcheck2' // (3) + body([ // (4) + clientId: value(consumer(regex('[0-9]{10}'))), + loanAmount: 99999 + ]) + headers { // (5) + header('Content-Type', 'application/vnd.fraud.v1+json') + } + } + response { // (6) + status 200 // (7) + body([ // (8) + fraudCheckStatus: "FRAUD", + rejectionReason: "Amount too high" + ]) + headers { // (9) + header('Content-Type': value( + producer(regex('application/vnd.fraud.v1.json.*')), + consumer('application/vnd.fraud.v1+json')) + ) + } + } +} + +/* +Since we don't want to force on the user to hardcode values of fields that are dynamic +(timestamps, database ids etc.), one can provide parametrize those entries by using the +`value(consumer(...), producer(...))` method. That way what's present in the `consumer` +section will end up in the produced stub. What's there in the `producer` will end up in the +autogenerated test. If you provide only the regular expression side without the concrete +value then Spring Cloud Contract will generate one for you. + +From the Consumer perspective, when shooting a request in the integration test: + +(1) - If the consumer sends a request +(2) - With the "PUT" method +(3) - to the URL "/fraudcheck" +(4) - with the JSON body that + * has a field `clientId` that matches a regular expression `[0-9]{10}` + * has a field `loanAmount` that is equal to `99999` +(5) - with header `Content-Type` equal to `application/vnd.fraud.v1+json` +(6) - then the response will be sent with +(7) - status equal `200` +(8) - and JSON body equal to + { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" } +(9) - with header `Content-Type` equal to `application/vnd.fraud.v1+json` + +From the Producer perspective, in the autogenerated producer-side test: + +(1) - A request will be sent to the producer +(2) - With the "PUT" method +(3) - to the URL "/fraudcheck" +(4) - with the JSON body that + * has a field `clientId` that will have a generated value that matches a regular expression `[0-9]{10}` + * has a field `loanAmount` that is equal to `99999` +(5) - with header `Content-Type` equal to `application/vnd.fraud.v1+json` +(6) - then the test will assert if the response has been sent with +(7) - status equal `200` +(8) - and JSON body equal to + { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" } +(9) - with header `Content-Type` matching `application/vnd.fraud.v1+json.*` + */ \ No newline at end of file diff --git a/samples/standalone/contracts/com/example/server/client3/expectation.groovy b/samples/standalone/contracts/com/example/server/client3/expectation.groovy new file mode 100644 index 0000000000..47958d8ae9 --- /dev/null +++ b/samples/standalone/contracts/com/example/server/client3/expectation.groovy @@ -0,0 +1,67 @@ +package contracts + +org.springframework.cloud.contract.spec.Contract.make { + request { // (1) + method 'PUT' // (2) + url '/fraudcheck3' // (3) + body([ // (4) + clientId: value(consumer(regex('[0-9]{10}'))), + loanAmount: 99999 + ]) + headers { // (5) + header('Content-Type', 'application/vnd.fraud.v1+json') + } + } + response { // (6) + status 200 // (7) + body([ // (8) + fraudCheckStatus: "FRAUD", + rejectionReason: "Amount too high" + ]) + headers { // (9) + header('Content-Type': value( + producer(regex('application/vnd.fraud.v1.json.*')), + consumer('application/vnd.fraud.v1+json')) + ) + } + } +} + +/* +Since we don't want to force on the user to hardcode values of fields that are dynamic +(timestamps, database ids etc.), one can provide parametrize those entries by using the +`value(consumer(...), producer(...))` method. That way what's present in the `consumer` +section will end up in the produced stub. What's there in the `producer` will end up in the +autogenerated test. If you provide only the regular expression side without the concrete +value then Spring Cloud Contract will generate one for you. + +From the Consumer perspective, when shooting a request in the integration test: + +(1) - If the consumer sends a request +(2) - With the "PUT" method +(3) - to the URL "/fraudcheck" +(4) - with the JSON body that + * has a field `clientId` that matches a regular expression `[0-9]{10}` + * has a field `loanAmount` that is equal to `99999` +(5) - with header `Content-Type` equal to `application/vnd.fraud.v1+json` +(6) - then the response will be sent with +(7) - status equal `200` +(8) - and JSON body equal to + { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" } +(9) - with header `Content-Type` equal to `application/vnd.fraud.v1+json` + +From the Producer perspective, in the autogenerated producer-side test: + +(1) - A request will be sent to the producer +(2) - With the "PUT" method +(3) - to the URL "/fraudcheck" +(4) - with the JSON body that + * has a field `clientId` that will have a generated value that matches a regular expression `[0-9]{10}` + * has a field `loanAmount` that is equal to `99999` +(5) - with header `Content-Type` equal to `application/vnd.fraud.v1+json` +(6) - then the test will assert if the response has been sent with +(7) - status equal `200` +(8) - and JSON body equal to + { "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" } +(9) - with header `Content-Type` matching `application/vnd.fraud.v1+json.*` + */ \ No newline at end of file diff --git a/samples/standalone/contracts/com/example/server/pom.xml b/samples/standalone/contracts/com/example/server/pom.xml new file mode 100644 index 0000000000..ad18558a2d --- /dev/null +++ b/samples/standalone/contracts/com/example/server/pom.xml @@ -0,0 +1,107 @@ + + + 4.0.0 + + com.example + server + 0.0.1-SNAPSHOT + + Server Stubs + POM used to install locally stubs for consumer side + + + org.springframework.boot + spring-boot-starter-parent + 1.4.0.BUILD-SNAPSHOT + + + + + UTF-8 + 1.8 + 1.0.0.BUILD-SNAPSHOT + Camden.BUILD-SNAPSHOT + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud-dependencies.version} + pom + import + + + + + + + + org.springframework.cloud + spring-cloud-contract-maven-plugin + ${spring-cloud-contract.version} + true + + + ${project.basedir} + + + + + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + true + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + spring-releases + Spring Releases + https://repo.spring.io/release + + false + + + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + true + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + spring-releases + Spring Releases + https://repo.spring.io/release + + false + + + + + diff --git a/samples/standalone/contracts/mvnw b/samples/standalone/contracts/mvnw new file mode 100755 index 0000000000..fc7efd17d0 --- /dev/null +++ b/samples/standalone/contracts/mvnw @@ -0,0 +1,234 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven2 Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # + # Look for the Apple JDKs first to preserve the existing behaviour, and then look + # for the new JDKs provided by Oracle. + # + if [ -z "$JAVA_HOME" ] && [ -L /System/Library/Frameworks/JavaVM.framework/Versions/CurrentJDK ] ; then + # + # Apple JDKs + # + export JAVA_HOME=/System/Library/Frameworks/JavaVM.framework/Versions/CurrentJDK/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -L /System/Library/Java/JavaVirtualMachines/CurrentJDK ] ; then + # + # Apple JDKs + # + export JAVA_HOME=/System/Library/Java/JavaVirtualMachines/CurrentJDK/Contents/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -L "/Library/Java/JavaVirtualMachines/CurrentJDK" ] ; then + # + # Oracle JDKs + # + export JAVA_HOME=/Library/Java/JavaVirtualMachines/CurrentJDK/Contents/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -x "/usr/libexec/java_home" ]; then + # + # Apple JDKs + # + export JAVA_HOME=`/usr/libexec/java_home` + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Migwn, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" + # TODO classpath? +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + local basedir=$(pwd) + local wdir=$(pwd) + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + wdir=$(cd "$wdir/.."; pwd) + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-$(find_maven_basedir)} +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CMD_LINE_ARGS + diff --git a/samples/standalone/contracts/mvnw.cmd b/samples/standalone/contracts/mvnw.cmd new file mode 100644 index 0000000000..001048081d --- /dev/null +++ b/samples/standalone/contracts/mvnw.cmd @@ -0,0 +1,145 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven2 Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +set MAVEN_CMD_LINE_ARGS=%MAVEN_CONFIG% %* + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" + +set WRAPPER_JAR=""%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CMD_LINE_ARGS% +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% diff --git a/samples/standalone/contracts/pom.xml b/samples/standalone/contracts/pom.xml new file mode 100644 index 0000000000..33fcad34dc --- /dev/null +++ b/samples/standalone/contracts/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + com.example.standalone + contracts + 0.0.1-SNAPSHOT + + Spring Cloud Contract Verifier Http Server Sample + Spring Cloud Contract Verifier Http Server Sample + + + UTF-8 + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + + contracts + prepare-package + + single + + + true + ${basedir}/src/assembly/contracts.xml + + false + + + + + + + + diff --git a/samples/standalone/contracts/src/assembly/contracts.xml b/samples/standalone/contracts/src/assembly/contracts.xml new file mode 100644 index 0000000000..89fb589f6c --- /dev/null +++ b/samples/standalone/contracts/src/assembly/contracts.xml @@ -0,0 +1,23 @@ + + project + + jar + + false + + + ${project.basedir} + / + true + + **/${project.build.directory}/** + mvnw + mvnw.cmd + .mvn/** + src/** + + + + \ No newline at end of file diff --git a/spring-cloud-contract-dependencies/pom.xml b/spring-cloud-contract-dependencies/pom.xml index 5552ffb5c1..6a848d3c49 100644 --- a/spring-cloud-contract-dependencies/pom.xml +++ b/spring-cloud-contract-dependencies/pom.xml @@ -17,6 +17,7 @@ 2.1.7 0.4.7 1.0.2.v20150114 + 1.1.3.BUILD-SNAPSHOT @@ -60,6 +61,13 @@ spring-cloud-starter-contract-stub-runner-jetty ${project.version} + + org.springframework.cloud + spring-cloud-commons-dependencies + ${spring-cloud-commons.version} + pom + import + com.github.tomakehurst wiremock diff --git a/spring-cloud-contract-stub-runner/src/main/java/org/springframework/cloud/contract/stubrunner/ContractDownloader.java b/spring-cloud-contract-stub-runner/src/main/java/org/springframework/cloud/contract/stubrunner/ContractDownloader.java new file mode 100644 index 0000000000..e37b8c2ba4 --- /dev/null +++ b/spring-cloud-contract-stub-runner/src/main/java/org/springframework/cloud/contract/stubrunner/ContractDownloader.java @@ -0,0 +1,91 @@ +package org.springframework.cloud.contract.stubrunner; + +import java.io.File; +import java.lang.invoke.MethodHandles; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.cloud.contract.verifier.config.ContractVerifierConfigProperties; +import org.springframework.util.StringUtils; + +/** + * Downloads a JAR with contracts and sets up the plugin configuration with proper + * inclusion patterns + * + * @author Marcin Grzejszczak + * + * @since 1.0.0 + */ +public class ContractDownloader { + + private static final Log log = LogFactory.getLog(MethodHandles.lookup().lookupClass()); + + private final StubDownloader stubDownloader; + private final StubConfiguration contractsJarStubConfiguration; + private final String contractsPath; + private final String projectGroupId; + private final String projectArtifactId; + + public ContractDownloader(StubDownloader stubDownloader, + StubConfiguration contractsJarStubConfiguration, + String contractsPath, String projectGroupId, String projectArtifactId) { + this.stubDownloader = stubDownloader; + this.contractsJarStubConfiguration = contractsJarStubConfiguration; + this.contractsPath = contractsPath; + this.projectGroupId = projectGroupId; + this.projectArtifactId = projectArtifactId; + } + + /** + * Downloads JAR containing all the contracts. Plugin configuration gets updated with + * the inclusion pattern for the downloaded contracts. The JAR with the contracts contains all + * the contracts for all the projects. We're interested only in its subset. + * + * @param config - Plugin configuration that will get updated with the inclusion pattern + * @return location of the unpacked downloaded stubs + */ + public File unpackedDownloadedContracts(ContractVerifierConfigProperties config) { + File contractsDirectory = unpackAndDownloadContracts(); + updatePropertiesWithInclusion(contractsDirectory, config); + return contractsDirectory; + } + + public ContractVerifierConfigProperties updatePropertiesWithInclusion(File contractsDirectory, + ContractVerifierConfigProperties config) { + String pattern = StringUtils.hasText(this.contractsPath) ? patternFromProperty(contractsDirectory) : + groupArtifactToPattern(contractsDirectory); + log.info("Pattern to pick contracts equals [" + pattern + "]"); + config.setIncludedContracts(pattern); + return config; + } + + private String patternFromProperty(File contractsDirectory) { + return "^" + contractsDirectory.getAbsolutePath() + contractsPath() + ".*$"; + } + + private String contractsPath() { + return this.contractsPath.startsWith(File.separator) ? this.contractsPath : File.separator + this.contractsPath; + } + + private File unpackAndDownloadContracts() { + log.info("Will download contracts for [" + this.contractsJarStubConfiguration + "]"); + Map.Entry unpackedContractStubs = this.stubDownloader + .downloadAndUnpackStubJar(null, this.contractsJarStubConfiguration); + if (unpackedContractStubs == null) { + throw new IllegalStateException("The contracts failed to be downloaded!"); + } + return unpackedContractStubs.getValue(); + } + + private String groupArtifactToPattern(File contractsDirectory) { + return "^" + + contractsDirectory.getAbsolutePath() + + File.separator + + this.projectGroupId.replace(".", File.separator) + + File.separator + + this.projectArtifactId + + File.separator + + ".*$"; + } +} diff --git a/spring-cloud-contract-stub-runner/src/main/java/org/springframework/cloud/contract/stubrunner/StubConfiguration.java b/spring-cloud-contract-stub-runner/src/main/java/org/springframework/cloud/contract/stubrunner/StubConfiguration.java index 51b2417c8c..23a6caccfd 100644 --- a/spring-cloud-contract-stub-runner/src/main/java/org/springframework/cloud/contract/stubrunner/StubConfiguration.java +++ b/spring-cloud-contract-stub-runner/src/main/java/org/springframework/cloud/contract/stubrunner/StubConfiguration.java @@ -91,10 +91,17 @@ public class StubConfiguration { return ""; } return StringUtils.arrayToDelimitedString( - new String[] { this.groupId, this.artifactId, this.version, this.classifier }, + new String[] { nullCheck(this.groupId), + nullCheck(this.artifactId), + nullCheck(this.version), + nullCheck(this.classifier) }, STUB_COLON_DELIMITER); } + private String nullCheck(String value) { + return StringUtils.hasText(value) ? value : ""; + } + public boolean groupIdAndArtifactMatches(String ivyNotationAsString) { String[] parts = ivyNotationFrom(ivyNotationAsString); String groupId = parts[0]; @@ -117,6 +124,10 @@ public class StubConfiguration { return this.classifier; } + public String getVersion() { + return this.version; + } + @Override public int hashCode() { final int prime = 31; diff --git a/spring-cloud-contract-stub-runner/src/test/groovy/org/springframework/cloud/contract/stubrunner/ContractDownloaderSpec.groovy b/spring-cloud-contract-stub-runner/src/test/groovy/org/springframework/cloud/contract/stubrunner/ContractDownloaderSpec.groovy new file mode 100644 index 0000000000..89ee9e369a --- /dev/null +++ b/spring-cloud-contract-stub-runner/src/test/groovy/org/springframework/cloud/contract/stubrunner/ContractDownloaderSpec.groovy @@ -0,0 +1,41 @@ +package org.springframework.cloud.contract.stubrunner + +import org.springframework.cloud.contract.verifier.config.ContractVerifierConfigProperties +import spock.lang.Specification +/** + * @author Marcin Grzejszczak + */ +class ContractDownloaderSpec extends Specification { + + StubDownloader stubDownloader = Stub() + StubConfiguration stubConfiguration = new StubConfiguration('') + File file = new File('/some/path/to/somewhere') + + def 'should set inclusion pattern on config when path pattern was explicitly provided with a separator at the beginning'() { + given: + String contractPath = '/a/b/c/d' + ContractDownloader contractDownloader = new ContractDownloader(stubDownloader, + stubConfiguration, contractPath, '', '') + ContractVerifierConfigProperties properties = new ContractVerifierConfigProperties() + and: + stubDownloader.downloadAndUnpackStubJar(_, _) >> new AbstractMap.SimpleEntry(stubConfiguration, file) + when: + contractDownloader.unpackedDownloadedContracts(properties) + then: + properties.includedContracts == '^/some/path/to/somewhere/a/b/c/d.*$' + } + + def 'should set inclusion pattern on config when path pattern was explicitly provided without a separator at the beginning'() { + given: + String contractPath = 'a/b/c/d' + ContractDownloader contractDownloader = new ContractDownloader(stubDownloader, + stubConfiguration, contractPath, '', '') + ContractVerifierConfigProperties properties = new ContractVerifierConfigProperties() + and: + stubDownloader.downloadAndUnpackStubJar(_, _) >> new AbstractMap.SimpleEntry(stubConfiguration, file) + when: + contractDownloader.unpackedDownloadedContracts(properties) + then: + properties.includedContracts == '^/some/path/to/somewhere/a/b/c/d.*$' + } +} 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 3c2f456076..0569637047 100644 --- a/spring-cloud-contract-tools/spring-cloud-contract-converters/pom.xml +++ b/spring-cloud-contract-tools/spring-cloud-contract-converters/pom.xml @@ -14,6 +14,10 @@ Spring Cloud Contract Converters 1.8 + + org.springframework + spring-context + org.springframework.cloud spring-cloud-contract-verifier diff --git a/spring-cloud-contract-tools/spring-cloud-contract-converters/src/main/groovy/org/springframework/cloud/contract/verifier/wiremock/RecursiveFilesConverter.groovy b/spring-cloud-contract-tools/spring-cloud-contract-converters/src/main/groovy/org/springframework/cloud/contract/verifier/wiremock/RecursiveFilesConverter.groovy index 5be7dec7ab..8c8d5d6ddc 100644 --- a/spring-cloud-contract-tools/spring-cloud-contract-converters/src/main/groovy/org/springframework/cloud/contract/verifier/wiremock/RecursiveFilesConverter.groovy +++ b/spring-cloud-contract-tools/spring-cloud-contract-converters/src/main/groovy/org/springframework/cloud/contract/verifier/wiremock/RecursiveFilesConverter.groovy @@ -54,7 +54,8 @@ class RecursiveFilesConverter { } void processFiles() { - ContractFileScanner scanner = new ContractFileScanner(properties.contractsDslDir, properties.excludedFiles as Set, [] as Set) + ContractFileScanner scanner = new ContractFileScanner(properties.contractsDslDir, + properties.excludedFiles as Set, [] as Set, properties.includedContracts) ListMultimap contracts = scanner.findContracts() if (log.isDebugEnabled()) { log.debug("Found the following contracts $contracts") diff --git a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/ContractVerifierExtension.groovy b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/ContractVerifierExtension.groovy new file mode 100644 index 0000000000..01afc18dc5 --- /dev/null +++ b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/ContractVerifierExtension.groovy @@ -0,0 +1,127 @@ +package org.springframework.cloud.contract.verifier.plugin + +import groovy.transform.ToString +import org.springframework.cloud.contract.verifier.config.TestFramework +import org.springframework.cloud.contract.verifier.config.TestMode + +/** + * @author Marcin Grzejszczak + */ +@ToString +class ContractVerifierExtension { + /** + * For which unit test library tests should be generated + */ + TestFramework targetFramework = TestFramework.JUNIT + + /** + * Which mechanism should be used to invoke REST calls during tests + */ + TestMode testMode = TestMode.MOCKMVC + + /** + * Base package for generated tests + */ + String basePackageForTests + + /** + * Class which all generated tests should extend + */ + String baseClassForTests + + /** + * Suffix for generated test classes, like Spec or Test + */ + String nameSuffixForTests + + /** + * Rule class that should be added to generated tests + */ + String ruleClassForTests + + /** + * Patterns that should not be taken into account for processing + */ + List excludedFiles = [] + + /** + * Patterns for which generated tests should be @Ignored + */ + List ignoredFiles = [] + + /** + * Imports that should be added to generated tests + */ + String[] imports = [] + + /** + * Static imports that should be added to generated tests + */ + String[] staticImports = [] + + /** + * Directory containing contracts written using the GroovyDSL + */ + File contractsDslDir + + /** + * Test source directory where tests generated from Groovy DSL should be placed + */ + File generatedTestSourcesDir + + /** + * Dir where the generated WireMock stubs from Groovy DSL should be placed. + * You can then mention them in your packaging task to create jar with stubs + */ + File stubsOutputDir + + /** + * Suffix for the generated Stubs Jar task + */ + String stubsSuffix = 'stubs' + + /** + * Incubating feature. You can check the size of JSON arrays. If not turned on + * explicitly will be disabled. + */ + Boolean assertJsonSize = false + + /** + * The URL from which a JAR containing the contracts should get downloaded. If not provided + * but artifactid / coordinates notation was provided then the current Maven's build repositories will be + * taken into consideration + */ + String contractsRepositoryUrl + + /** + * Dependency that contains packaged contracts + */ + Dependency contractDependency = new Dependency() + + /** + * The path in the JAR with all the contracts where contracts for this particular service lay. + * If not provided will be resolved to {@code groupid/artifactid}. Example: + *

+ * If {@code groupid} is {@code com.example} and {@code artifactid} is {@code service} then the resolved path will be + * {@code /com/example/artifactid} + */ + String contractsPath + + /** + * If {@code true} then JAR with contracts will be taken from local maven repository + */ + boolean contractsWorkOffline + + void contractDependency(@DelegatesTo(Dependency) Closure closure) { + closure.delegate = contractDependency + closure.call() + } + + static class Dependency { + String groupId + String artifactId + String classifier + String version + String stringNotation + } +} diff --git a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/ExtensionToProperties.groovy b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/ExtensionToProperties.groovy new file mode 100644 index 0000000000..6e0262ebfe --- /dev/null +++ b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/ExtensionToProperties.groovy @@ -0,0 +1,31 @@ +package org.springframework.cloud.contract.verifier.plugin + +import groovy.transform.PackageScope +import org.springframework.cloud.contract.verifier.config.ContractVerifierConfigProperties + +/** + * @author Marcin Grzejszczak + */ +@PackageScope +class ExtensionToProperties { + + protected static ContractVerifierConfigProperties fromExtension(ContractVerifierExtension extension) { + return new ContractVerifierConfigProperties( + targetFramework: extension.targetFramework, + testMode: extension.testMode, + basePackageForTests: extension.basePackageForTests, + baseClassForTests: extension.baseClassForTests, + nameSuffixForTests: extension.nameSuffixForTests, + ruleClassForTests: extension.ruleClassForTests, + excludedFiles: extension.excludedFiles, + ignoredFiles: extension.ignoredFiles, + imports: extension.imports, + staticImports: extension.staticImports, + contractsDslDir: extension.contractsDslDir, + generatedTestSourcesDir: extension.generatedTestSourcesDir, + stubsOutputDir: extension.stubsOutputDir, + stubsSuffix: extension.stubsSuffix, + assertJsonSize: extension.assertJsonSize + ) + } +} diff --git a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/GenerateServerTestsTask.groovy b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/GenerateServerTestsTask.groovy index ce5301b3fd..055e58ba33 100644 --- a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/GenerateServerTestsTask.groovy +++ b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/GenerateServerTestsTask.groovy @@ -38,7 +38,7 @@ class GenerateServerTestsTask extends ConventionTask { File generatedTestSourcesDir //TODO: How to deal with @Input*, @Output* and that domain object? - ContractVerifierConfigProperties configProperties + ContractVerifierExtension configProperties @TaskAction void generate() { @@ -51,7 +51,9 @@ class GenerateServerTestsTask extends ConventionTask { try { //TODO: What with that? How to pass? - TestGenerator generator = new TestGenerator(getConfigProperties()) + ContractVerifierConfigProperties props = ExtensionToProperties.fromExtension(getConfigProperties()) + props.contractsDslDir = getContractsDslDir() + TestGenerator generator = new TestGenerator(props) int generatedClasses = generator.generate() project.logger.info("Generated {} test classes", generatedClasses) } catch (ContractVerifierException e) { diff --git a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/GenerateWireMockClientStubsFromDslTask.groovy b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/GenerateWireMockClientStubsFromDslTask.groovy index a3693e28c8..eed1d46cc6 100644 --- a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/GenerateWireMockClientStubsFromDslTask.groovy +++ b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/GenerateWireMockClientStubsFromDslTask.groovy @@ -38,18 +38,19 @@ class GenerateWireMockClientStubsFromDslTask extends ConventionTask { @OutputDirectory File stubsOutputDir - ContractVerifierConfigProperties configProperties + ContractVerifierExtension configProperties @TaskAction void generate() { logger.info("Spring Cloud Contract Verifier Plugin: Invoking DSL to WireMock client stubs conversion") logger.debug("From '${getContractsDslDir()}' to '${getStubsOutputDir()}'") - ContractVerifierConfigProperties props = getConfigProperties() + ContractVerifierConfigProperties props = ExtensionToProperties.fromExtension(getConfigProperties()) + props.contractsDslDir = getContractsDslDir() File outMappingsDir = props.stubsOutputDir != null ? new File(props.stubsOutputDir, DEFAULT_MAPPINGS_FOLDER) : new File(project.buildDir, "stubs/$DEFAULT_MAPPINGS_FOLDER") RecursiveFilesConverter converter = new RecursiveFilesConverter( new DslToWireMockClientConverter(), - getConfigProperties(), outMappingsDir) + props, outMappingsDir) converter.processFiles() } } diff --git a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/GradleContractsDownloader.groovy b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/GradleContractsDownloader.groovy new file mode 100644 index 0000000000..8ee920a0e3 --- /dev/null +++ b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/GradleContractsDownloader.groovy @@ -0,0 +1,82 @@ +package org.springframework.cloud.contract.verifier.plugin + +import groovy.transform.PackageScope +import org.gradle.api.Project +import org.gradle.api.logging.Logger +import org.springframework.cloud.contract.stubrunner.AetherStubDownloader +import org.springframework.cloud.contract.stubrunner.ContractDownloader +import org.springframework.cloud.contract.stubrunner.StubConfiguration +import org.springframework.cloud.contract.stubrunner.StubRunnerOptionsBuilder +import org.springframework.util.StringUtils + +import java.util.concurrent.ConcurrentHashMap +/** + * @author Marcin Grzejszczak + */ +@PackageScope +class GradleContractsDownloader { + + private static final String LATEST_VERSION = '+' + + private final Project project + private final Logger log + private static final Map downloadedContract = new ConcurrentHashMap<>() + + GradleContractsDownloader(Project project, Logger log) { + this.project = project + this.log = log + } + + File downloadAndUnpackContractsIfRequired(ContractVerifierExtension extension) { + File defaultContractsDir = extension.contractsDslDir + // download contracts, unzip them and pass as output directory + if (shouldDownloadContracts(extension)) { + this.log.info("For project [${this.project.name}] Download dependency is provided - will download contract jars") + StubConfiguration configuration = stubConfiguration(extension.contractDependency) + if (downloadedContract.get(configuration)) { + this.log.info("For project [${this.project.name}] Returning the cached location of the contracts") + return downloadedContract.get(configuration) + } + File downloadedContracts = contractDownloader(extension, configuration).unpackedDownloadedContracts( + ExtensionToProperties.fromExtension(extension)) + downloadedContract.put(configuration, downloadedContracts) + return downloadedContracts + } + this.log.info("For project [${this.project.name}] will use contracts provided in the folder [" + defaultContractsDir + "]") + return defaultContractsDir + } + + private boolean shouldDownloadContracts(ContractVerifierExtension extension) { + return StringUtils.hasText(extension.contractsRepositoryUrl) && + (StringUtils.hasText(extension.contractDependency.artifactId) || + StringUtils.hasText(extension.contractDependency.stringNotation)) + } + + private ContractDownloader contractDownloader(ContractVerifierExtension extension, StubConfiguration configuration) { + return new ContractDownloader(stubDownloader(extension), configuration, + extension.contractsPath, this.project.group as String, this.project.name) + } + + private AetherStubDownloader stubDownloader(ContractVerifierExtension extension) { + return new AetherStubDownloader( + new StubRunnerOptionsBuilder() + .withStubRepositoryRoot(extension.contractsRepositoryUrl) + .withWorkOffline(extension.contractsWorkOffline) + .build()) + } + + private StubConfiguration stubConfiguration(ContractVerifierExtension.Dependency contractDependency) { + String groupId = contractDependency.groupId + String artifactId = contractDependency.artifactId + String version = StringUtils.hasText(contractDependency.version) ? + contractDependency.version : LATEST_VERSION + String classifier = contractDependency.classifier + String stringNotation = contractDependency.stringNotation + if (StringUtils.hasText(stringNotation)) { + StubConfiguration stubConfiguration = new StubConfiguration(stringNotation) + return new StubConfiguration(stubConfiguration.groupId, stubConfiguration.artifactId, + stubConfiguration.version, contractDependency.classifier) + } + return new StubConfiguration(groupId, artifactId, version, classifier) + } +} diff --git a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/SpringCloudContractVerifierGradlePlugin.groovy b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/SpringCloudContractVerifierGradlePlugin.groovy index d8edc0944a..d228a6374c 100644 --- a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/SpringCloudContractVerifierGradlePlugin.groovy +++ b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/main/groovy/org/springframework/cloud/contract/verifier/plugin/SpringCloudContractVerifierGradlePlugin.groovy @@ -24,7 +24,6 @@ import org.gradle.api.publish.maven.MavenPublication import org.gradle.api.publish.maven.plugins.MavenPublishPlugin import org.gradle.api.tasks.Copy import org.gradle.jvm.tasks.Jar -import org.springframework.cloud.contract.verifier.config.ContractVerifierConfigProperties /** * Gradle plugin for Spring Cloud Contract Verifier that from the DSL contract can *
    @@ -62,14 +61,15 @@ class SpringCloudContractVerifierGradlePlugin implements Plugin { void apply(Project project) { this.project = project project.plugins.apply(GroovyPlugin) - ContractVerifierConfigProperties extension = project.extensions.create(EXTENSION_NAME, ContractVerifierConfigProperties) + ContractVerifierExtension extension = project.extensions.create(EXTENSION_NAME, ContractVerifierExtension) + GradleContractsDownloader downloader = new GradleContractsDownloader(this.project, this.project.logger) project.check.dependsOn(GENERATE_SERVER_TESTS_TASK_NAME) setConfigurationDefaults(extension) - createGenerateTestsTask(extension) - createAndConfigureGenerateWireMockClientStubsFromDslTask(extension) + createGenerateTestsTask(downloader, extension) + createAndConfigureGenerateWireMockClientStubsFromDslTask(downloader, extension) Task stubsJar = createAndConfigureStubsJarTasks(extension) - createAndConfigureCopyContractsTask(stubsJar, extension) - createAndConfigureMavenPublishPlugin(stubsJar, extension) + createAndConfigureCopyContractsTask(stubsJar, downloader, extension) + createAndConfigureMavenPublishPlugin(stubsJar) addProjectDependencies(project) addIdeaTestSources(project, extension) } @@ -94,11 +94,11 @@ class SpringCloudContractVerifierGradlePlugin implements Plugin { project.dependencies.add("testCompile", "org.assertj:assertj-core:2.3.0") } - private void setConfigurationDefaults(ContractVerifierConfigProperties extension) { + private void setConfigurationDefaults(ContractVerifierExtension extension) { extension.with { - generatedTestSourcesDir = project.file("${project.buildDir}/generated-test-sources/contracts") - contractsDslDir = defaultContractsDir() //TODO: Use sourceset - basePackageForTests = 'org.springframework.cloud.contract.verifier.tests' + generatedTestSourcesDir = generatedTestSourcesDir ?: project.file("${project.buildDir}/generated-test-sources/contracts") + contractsDslDir = contractsDslDir ?: defaultContractsDir() //TODO: Use sourceset + basePackageForTests = basePackageForTests ?: 'org.springframework.cloud.contract.verifier.tests' stubsOutputDir = stubsOutputDir ?: project.file("${project.buildDir}/stubs") } } @@ -107,29 +107,31 @@ class SpringCloudContractVerifierGradlePlugin implements Plugin { return project.file("${project.rootDir}/src/test/resources/contracts") } - private void createGenerateTestsTask(ContractVerifierConfigProperties extension) { + private void createGenerateTestsTask(GradleContractsDownloader downloader, + ContractVerifierExtension extension) { Task task = project.tasks.create(GENERATE_SERVER_TESTS_TASK_NAME, GenerateServerTestsTask) task.description = "Generate server tests from the contracts" task.group = GROUP_NAME task.conventionMapping.with { - contractsDslDir = { extension.contractsDslDir } + contractsDslDir = { downloader.downloadAndUnpackContractsIfRequired(extension) } generatedTestSourcesDir = { extension.generatedTestSourcesDir } configProperties = { extension } } } - private void createAndConfigureGenerateWireMockClientStubsFromDslTask(ContractVerifierConfigProperties extension) { + private void createAndConfigureGenerateWireMockClientStubsFromDslTask( + GradleContractsDownloader downloader, ContractVerifierExtension extension) { Task task = project.tasks.create(DSL_TO_WIREMOCK_CLIENT_TASK_NAME, GenerateWireMockClientStubsFromDslTask) task.description = "Generate WireMock client stubs from the contracts" task.group = GROUP_NAME task.conventionMapping.with { - contractsDslDir = { extension.contractsDslDir } + contractsDslDir = { downloader.downloadAndUnpackContractsIfRequired(extension) } stubsOutputDir = { extension.stubsOutputDir } configProperties = { extension } } } - private Task createAndConfigureStubsJarTasks(ContractVerifierConfigProperties extension) { + private Task createAndConfigureStubsJarTasks(ContractVerifierExtension extension) { Task task = stubsTask() if (task) { project.logger.info("Spring Cloud Contract Verifier Plugin: Stubs jar task was present - won't create one. Remember about adding it to artifacts as an archive!") @@ -158,9 +160,11 @@ class SpringCloudContractVerifierGradlePlugin implements Plugin { } } - private Task createAndConfigureCopyContractsTask(Task stubs, ContractVerifierConfigProperties extension) { + private Task createAndConfigureCopyContractsTask(Task stubs, + GradleContractsDownloader downloader, + ContractVerifierExtension extension) { Task task = project.tasks.create(type: Copy, name: COPY_CONTRACTS_TASK_NAME) { - from { extension.contractsDslDir } + from { downloader.downloadAndUnpackContractsIfRequired(extension) } into { extension.stubsOutputDir != null ? project.file("${extension.stubsOutputDir}/contracts") : project.file("${project.buildDir}/stubs/contracts") } } @@ -170,7 +174,7 @@ class SpringCloudContractVerifierGradlePlugin implements Plugin { return task } - private void createAndConfigureMavenPublishPlugin(Task stubsTask, ContractVerifierConfigProperties extension) { + private void createAndConfigureMavenPublishPlugin(Task stubsTask) { if (!classIsOnClasspath("org.gradle.api.publish.maven.plugins.MavenPublishPlugin")) { project.logger.debug("Maven Publish Plugin is not present - won't add default publication") return diff --git a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/groovy/org/springframework/cloud/contract/verifier/plugin/ContractVerifierIntegrationSpec.groovy b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/groovy/org/springframework/cloud/contract/verifier/plugin/ContractVerifierIntegrationSpec.groovy index 722be177a3..ced185b132 100644 --- a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/groovy/org/springframework/cloud/contract/verifier/plugin/ContractVerifierIntegrationSpec.groovy +++ b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/groovy/org/springframework/cloud/contract/verifier/plugin/ContractVerifierIntegrationSpec.groovy @@ -91,7 +91,7 @@ abstract class ContractVerifierIntegrationSpec extends Specification { } protected String[] checkAndPublishToMavenLocal() { - String[] args = ["check", "publishToMavenLocal", "--info"] as String[] + String[] args = ["check", "publishToMavenLocal", "--info", "--stacktrace"] as String[] if (WORK_OFFLINE) args << "--offline" return args } diff --git a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/groovy/org/springframework/cloud/contract/verifier/plugin/ContractVerifierSpec.groovy b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/groovy/org/springframework/cloud/contract/verifier/plugin/ContractVerifierSpec.groovy index 53af60f5b2..e9f6a3f0ad 100644 --- a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/groovy/org/springframework/cloud/contract/verifier/plugin/ContractVerifierSpec.groovy +++ b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/groovy/org/springframework/cloud/contract/verifier/plugin/ContractVerifierSpec.groovy @@ -6,7 +6,6 @@ import org.gradle.api.plugins.GroovyPlugin import org.gradle.api.publish.PublishingExtension import org.gradle.api.publish.maven.plugins.MavenPublishPlugin import org.gradle.testfixtures.ProjectBuilder -import org.springframework.cloud.contract.verifier.config.ContractVerifierConfigProperties import spock.lang.Specification class ContractVerifierSpec extends Specification { @@ -32,7 +31,7 @@ class ContractVerifierSpec extends Specification { project.plugins.apply(SpringCloudContractVerifierGradlePlugin) expect: - project.extensions.findByType(ContractVerifierConfigProperties) != null + project.extensions.findByType(ContractVerifierExtension) != null } def "should create generateContractTests task"() { diff --git a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/build.gradle b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/build.gradle index 099e9c5da0..7269ee6a6a 100644 --- a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/build.gradle +++ b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/build.gradle @@ -52,24 +52,10 @@ subprojects { configure([project(':fraudDetectionService'), project(':loanApplicationService')]) { apply plugin: 'spring-boot' - apply plugin: 'spring-cloud-contract' apply plugin: 'maven-publish' - ext { - contractsDir = file("mappings") - stubsOutputDirRoot = file("${project.buildDir}/production/${project.name}-stubs/") - } ext['jetty.version'] = '9.2.17.v20160517' - contracts { - targetFramework = 'Spock' - testMode = 'JaxRsClient' - baseClassForTests = 'org.springframework.cloud.MvcSpec' - contractsDslDir = file("${project.projectDir.absolutePath}/mappings/") - generatedTestSourcesDir = file("${project.buildDir}/generated-test-sources/") - stubsOutputDir = stubsOutputDirRoot - } - jar { version = '0.0.1' } @@ -114,16 +100,38 @@ configure([project(':fraudDetectionService'), project(':loanApplicationService') configure(project(':fraudDetectionService')) { test.dependsOn('generateWireMockClientStubs') + + apply plugin: 'spring-cloud-contract' + + ext { + contractsDir = file("mappings") + stubsOutputDirRoot = file("${project.buildDir}/production/${project.name}-stubs/") + } + ext['jetty.version'] = '9.2.17.v20160517' + + contracts { + targetFramework = 'Spock' + testMode = 'JaxRsClient' + baseClassForTests = 'org.springframework.cloud.MvcSpec' + contractsRepositoryUrl = "file://" + file("${project.rootDir.absolutePath}/m2repo/repository").absolutePath + contractDependency { + stringNotation = "com.example:jersey-contracts" + } + generatedTestSourcesDir = file("${project.buildDir}/generated-test-sources/") + stubsOutputDir = stubsOutputDirRoot + } } configure(project(':loanApplicationService')) { task copyCollaboratorStubs(type: Copy) { File fraudBuildDir = project(':fraudDetectionService').buildDir - from(new File(fraudBuildDir, "/production/${project(':fraudDetectionService').name}-stubs/")) - into "src/test/resources/" + from(new File(fraudBuildDir, "/production/${project(':fraudDetectionService').name}-stubs/")) { + include '**/*.json' + } + into "src/test/resources/mappings" } - generateContractTests.dependsOn('copyCollaboratorStubs') + test.dependsOn('copyCollaboratorStubs') } diff --git a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/fraudDetectionService/mappings/fraudDetectionService/shouldMarkClientAsFraud.groovy b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/fraudDetectionService/mappings/fraudDetectionService/shouldMarkClientAsFraud.groovy deleted file mode 100644 index 6c3106b3f0..0000000000 --- a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/fraudDetectionService/mappings/fraudDetectionService/shouldMarkClientAsFraud.groovy +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2013-2016 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import org.springframework.cloud.contract.spec.Contract - -Contract.make { - request { - method """PUT""" - url """/fraudcheck""" - body(""" - { - "clientPesel":"${value(consumer(regex('[0-9]{10}')), producer('1234567890'))}", - "loanAmount":99999} - """ - ) - headers { - header("""Content-Type""", """application/vnd.fraud.v1+json""") - } - - } - response { - status 200 - body( """{ - "fraudCheckStatus": "${value(consumer('FRAUD'), producer(regex('[A-Z]{5}')))}", - "rejectionReason": "Amount too high" -}""") - headers { - header('Content-Type': value( - producer(regex('application/vnd.fraud.v1.json.*')), - consumer('application/vnd.fraud.v1+json')) - ) - } - } - -} diff --git a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/fraudDetectionService/mappings/fraudDetectionService/shouldMarkClientAsNotFraud.groovy b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/fraudDetectionService/mappings/fraudDetectionService/shouldMarkClientAsNotFraud.groovy deleted file mode 100644 index 00d5236743..0000000000 --- a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/fraudDetectionService/mappings/fraudDetectionService/shouldMarkClientAsNotFraud.groovy +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2013-2016 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import org.springframework.cloud.contract.spec.Contract - -Contract.make { - request { - method 'PUT' - url '/fraudcheck' - body(""" - { - "clientPesel":"${value(consumer(regex('[0-9]{10}')), producer('1234567890'))}", - "loanAmount":123.123 - } - """ - ) - headers { - header('Content-Type', 'application/vnd.fraud.v1+json') - } - - } - response { - status 200 - body( - fraudCheckStatus: "OK", - rejectionReason: $(consumer(null), producer(execute('assertThatRejectionReasonIsNull($it)'))) - ) - headers { - header('Content-Type': value( - producer(regex('application/vnd.fraud.v1.json.*')), - consumer('application/vnd.fraud.v1+json')) - ) - } - } - -} diff --git a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/m2repo/repository/com/example/jersey-contracts/0.0.1-SNAPSHOT/jersey-contracts-0.0.1-SNAPSHOT.jar b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/m2repo/repository/com/example/jersey-contracts/0.0.1-SNAPSHOT/jersey-contracts-0.0.1-SNAPSHOT.jar new file mode 100644 index 0000000000000000000000000000000000000000..93a77ae4092e64db826da1322494545a2da1fcc6 GIT binary patch literal 3628 zcmbtXc|4SR7axQ{E@?8BB(iT!MwSSZnYq>xW9OE+W0Gy``^b`!klmn2wvy~ymMcp) zgdyvdA!P67QrZx`Gq?Aarta;2-uFD8=lMK;obNfm-*bN7bH2}M1ExLf000mO5FjY) z0jS1Edr*yydK)3kVDfq>BN$2#i9nkv7$Mhp%iIE^oI~f z$z@}=jp;dp|LT)q)F5Tup37cr{mRpa-K`fwJsuU#1AEzFgA!mlW{Zq8Gmxe6i5r@~ z1@#>P31Fq*aoxT|wb&&K+=;dQX$z*U7amo!WT@QLYM$bJNykA|e@Q1V|c) z)=r=|lbyC$wZ^<59O#0#z@KK`ubOy`9j>r9KL{zLhUiI8Yc(WOev-w$#FXDXQ_i%Y z%W5j#Tq8Hyy#8XzM^Sx+=SAa+enHW3srP6PL@`*h zMszuhWKeyMT?o}%YeF!|uPNScAr1aQIo*oIBVURYnQM%b+l~3LkM+i z#z9jvyX^r82~J6QK_DEqQ0WiRuPF^tY#5#SiH>a`1LZ0n9BQlfgPMs{A#w6dhueQl9h}OC|P?kCXp0_ zt0z6vj5f9Y6(&YBky8xMJ``8nwf^kZVeDI=Z8$ z{cin;jmhX!Yb=Ly@VndiK9Ujl+@hiLg-feAcI;W;#|GRBYKFG#Sk8IX%bBbuq!;hP^vIQN7>$PPP8x;2}I)`EnM! zk#NX|#~iT+)>D`(CFW4ed9In}edKDV0N+8v?R#$|p6@hH9beug_CCYDFA>uR?rD>8} zY#|yAaRQl#SUqoZ{du*q$z^ZMySMR2PY=dAvz0sgFJwU-c~^%c&|#Imbupc?Ope=a zfeB~8_7=co(1F4VngI$lma6KL%l|r#ipWx zDw#6DhjV{<1Z(ha4j5^r%tyL77~StazP-LR+Ljho6(NvLVm2S-e!fi36(<_=k!qk0 z@Z*swXC*Fc=*7FWaYp&1Q+^l{NXsnG$ZS@4`#REjTynyvtKPcUj!~briIeWX~(@czv+aY}S)@84DTts$QeR2zM2>IrC#>E$g*q;iwJg4O_Cd zZc#5AqYJcDPD)TNK|D*Pf*6@+<(weeUDbugc$c~z655)MXm$p#i zf=5pkYRqrK8^OHuS1l(VT<(S1l?s>Vun>8j8-J^F4Z<{tu z;;fh*mUJx&;r8y5j&d_fJjz+Wv^Ue+xul{NtPz(7mA#&S`L@5NH>PFdVMaW5vnr$P zI(9r^pC{|>SSJR?kqx|Km$OmT`;)OwTRFlFV!`PIic1T_m-YALWKdD3*2R!o7n;`i zPFcUM%cs8iXK9j-8Q+I+6W{Kz_xuci@u-NE+T3Ow=46qPLJzfF;SzsPtK(`}oVP}4 z$k0|NiBvsp+IrHocupJWTLwxsWD_dP=L_Ir6;$SdwVMJZ5+&OBc$E1Q5WQ%adt;J- zrVt;+==i%SnDa9KKrlJGi=#`6AWQPI=h)7#2(aCG!rRI`yb#^m)L_Az%1OG!j^eO7 z1ye{LM+cZcR9vhZmKm4b7xd6W>z5}2rRTMVHmg@glx+_3PS_NsrP~Q1{3r5$T92+C zGYKHZj!e#nWc2V%%C&}xRUhRkwSriy=!_yQUB+koXLQLU_fhJ&hpxz|7jDs0!Vx8` z&Bk#fW;U5Alea4;PPnU`d+xxW;I8=0KgZv*WwS311>Jm~I&f{Tr)^hL>Y1>7SvhG* z+*S@)Rq}U*Wxq!km`)ooFoKx=UgT11Lp5%|3T?c7mb~;ZddW+3rddI_H1+vODen@% z^bu&@)Wh(n(RA)dqb2sEzJ6A{|AagIU)0A1-}nOe?S4TYja~!)6|f+D`d>u*`k3yX z?ew|m#lz=Z`}geL@wZq0b*@j8_$a`4r=#zrwBMJHM*F~qth)&KPcT{o{tWZo4g2;Y z>4VX? + + + + 4.0.0 + com.example + jersey-contracts + 0.0.1-SNAPSHOT + pom + diff --git a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/m2repo/repository/com/example/jersey-contracts/0.0.1-SNAPSHOT/maven-metadata-local.xml b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/m2repo/repository/com/example/jersey-contracts/0.0.1-SNAPSHOT/maven-metadata-local.xml new file mode 100644 index 0000000000..05d9ce3299 --- /dev/null +++ b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/m2repo/repository/com/example/jersey-contracts/0.0.1-SNAPSHOT/maven-metadata-local.xml @@ -0,0 +1,24 @@ + + + com.example + jersey-contracts + 0.0.1-SNAPSHOT + + + true + + 20160916125313 + + + jar + 0.0.1-SNAPSHOT + 20160916125313 + + + pom + 0.0.1-SNAPSHOT + 20160916125313 + + + + diff --git a/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/m2repo/repository/com/example/jersey-contracts/maven-metadata.xml b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/m2repo/repository/com/example/jersey-contracts/maven-metadata.xml new file mode 100644 index 0000000000..b3827fc5c5 --- /dev/null +++ b/spring-cloud-contract-tools/spring-cloud-contract-gradle-plugin/src/test/resources/functionalTest/sampleJerseyProject/m2repo/repository/com/example/jersey-contracts/maven-metadata.xml @@ -0,0 +1,28 @@ + + + + + com.example + jersey-contracts + 0.0.1-SNAPSHOT + + + 0.0.1-SNAPSHOT + + 20160409062112 + + diff --git a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/ConvertMojo.java b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/ConvertMojo.java index bbe5b76027..006e59128c 100644 --- a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/ConvertMojo.java +++ b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/ConvertMojo.java @@ -16,8 +16,10 @@ package org.springframework.cloud.contract.maven.verifier; import java.io.File; +import javax.inject.Inject; import org.apache.maven.execution.MavenSession; +import org.apache.maven.model.Dependency; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; @@ -27,6 +29,8 @@ import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.project.MavenProject; import org.apache.maven.shared.filtering.MavenResourcesFiltering; +import org.eclipse.aether.RepositorySystemSession; +import org.springframework.cloud.contract.maven.verifier.stubrunner.AetherStubDownloaderFactory; import org.springframework.cloud.contract.verifier.config.ContractVerifierConfigProperties; import org.springframework.cloud.contract.verifier.wiremock.DslToWireMockClientConverter; import org.springframework.cloud.contract.verifier.wiremock.RecursiveFilesConverter; @@ -40,6 +44,9 @@ import org.springframework.cloud.contract.verifier.wiremock.RecursiveFilesConver defaultPhase = LifecyclePhase.PROCESS_TEST_RESOURCES) public class ConvertMojo extends AbstractMojo { + @Parameter(defaultValue = "${repositorySystemSession}", readonly = true) + private RepositorySystemSession repoSession; + /** * Directory containing Spring Cloud Contract Verifier contracts written using the GroovyDSL */ @@ -72,9 +79,43 @@ public class ConvertMojo extends AbstractMojo { @Parameter(defaultValue = "${project}", readonly = true) private MavenProject project; + /** + * The URL from which a JAR containing the contracts should get downloaded. If not provided + * but artifactid / coordinates notation was provided then the current Maven's build repositories will be + * taken into consideration + */ + @Parameter(property = "contractsRepositoryUrl") + private String contractsRepositoryUrl; + + @Parameter(property = "contractDependency") + private Dependency contractDependency; + + /** + * The path in the JAR with all the contracts where contracts for this particular service lay. + * If not provided will be resolved to {@code groupid/artifactid}. Example: + *

    + * If {@code groupid} is {@code com.example} and {@code artifactid} is {@code service} then the resolved path will be + * {@code /com/example/artifactid} + */ + @Parameter(property = "contractsPath") + private String contractsPath; + + /** + * If {@code true} then JAR with contracts will be taken from local maven repository + */ + @Parameter(property = "contractsWorkOffline", defaultValue = "false") + private boolean contractsWorkOffline; + @Component(role = MavenResourcesFiltering.class, hint = "default") private MavenResourcesFiltering mavenResourcesFiltering; + private final AetherStubDownloaderFactory aetherStubDownloaderFactory; + + @Inject + public ConvertMojo(AetherStubDownloaderFactory aetherStubDownloaderFactory) { + this.aetherStubDownloaderFactory = aetherStubDownloaderFactory; + } + public void execute() throws MojoExecutionException, MojoFailureException { if (this.skip) { @@ -83,12 +124,18 @@ public class ConvertMojo extends AbstractMojo { this.skip)); return; } + // download contracts, unzip them and pass as output directory + ContractVerifierConfigProperties config = new ContractVerifierConfigProperties(); + File contractsDirectory = new MavenContractsDownloader(this.project, this.contractDependency, + this.contractsPath, this.contractsRepositoryUrl, this.contractsWorkOffline, getLog(), + this.aetherStubDownloaderFactory, this.repoSession).downloadAndUnpackContractsIfRequired(config, this.contractsDirectory); + getLog().info("Directory with contract is present at [" + contractsDirectory + "]"); new CopyContracts(this.project, this.mavenSession, this.mavenResourcesFiltering) - .copy(this.contractsDirectory, this.outputDirectory); + .copy(contractsDirectory, this.outputDirectory); - final ContractVerifierConfigProperties config = new ContractVerifierConfigProperties(); - config.setContractsDslDir(isInsideProject() ? this.contractsDirectory : this.source); + + config.setContractsDslDir(isInsideProject() ? contractsDirectory : this.source); config.setStubsOutputDir( isInsideProject() ? new File(this.outputDirectory, "mappings") : this.destination); @@ -100,6 +147,7 @@ public class ConvertMojo extends AbstractMojo { getLog().info(String.format("WireMock stubs mappings directory: %s", config.getStubsOutputDir())); + RecursiveFilesConverter converter = new RecursiveFilesConverter( new DslToWireMockClientConverter(), config); converter.processFiles(); diff --git a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/GenerateStubsMojo.java b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/GenerateStubsMojo.java index 98a14ca83f..88de449039 100644 --- a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/GenerateStubsMojo.java +++ b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/GenerateStubsMojo.java @@ -29,6 +29,9 @@ import org.apache.maven.project.MavenProjectHelper; import org.codehaus.plexus.archiver.Archiver; import org.codehaus.plexus.archiver.jar.JarArchiver; +/** + * Picks the converted .json files and creates a jar. Requires convert to be executed first + */ @Mojo(name = "generateStubs", defaultPhase = LifecyclePhase.PACKAGE, requiresProject = true) public class GenerateStubsMojo extends AbstractMojo { diff --git a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/GenerateTestsMojo.java b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/GenerateTestsMojo.java index e43ab91d6b..b041994013 100644 --- a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/GenerateTestsMojo.java +++ b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/GenerateTestsMojo.java @@ -18,6 +18,9 @@ package org.springframework.cloud.contract.maven.verifier; import java.io.File; import java.util.List; +import javax.inject.Inject; + +import org.apache.maven.model.Dependency; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; @@ -26,6 +29,8 @@ import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.plugins.annotations.ResolutionScope; import org.apache.maven.project.MavenProject; +import org.eclipse.aether.RepositorySystemSession; +import org.springframework.cloud.contract.maven.verifier.stubrunner.AetherStubDownloaderFactory; import org.springframework.cloud.contract.spec.ContractVerifierException; import org.springframework.cloud.contract.verifier.TestGenerator; import org.springframework.cloud.contract.verifier.config.ContractVerifierConfigProperties; @@ -36,6 +41,9 @@ import org.springframework.cloud.contract.verifier.config.TestMode; requiresDependencyResolution = ResolutionScope.TEST) public class GenerateTestsMojo extends AbstractMojo { + @Parameter(defaultValue = "${repositorySystemSession}", readonly = true) + private RepositorySystemSession repoSession; + @Parameter(property = "spring.cloud.contract.verifier.contractsDirectory", defaultValue = "${project.basedir}/src/test/resources/contracts") private File contractsDirectory; @@ -105,6 +113,40 @@ public class GenerateTestsMojo extends AbstractMojo { @Parameter(property = "skipTests", defaultValue = "false") private boolean skipTests; + /** + * The URL from which a JAR containing the contracts should get downloaded. If not provided + * but artifactid / coordinates notation was provided then the current Maven's build repositories will be + * taken into consideration + */ + @Parameter(property = "contractsRepositoryUrl") + private String contractsRepositoryUrl; + + @Parameter(property = "contractDependency") + private Dependency contractDependency; + + /** + * The path in the JAR with all the contracts where contracts for this particular service lay. + * If not provided will be resolved to {@code groupid/artifactid}. Example: + *

    + * If {@code groupid} is {@code com.example} and {@code artifactid} is {@code service} then the resolved path will be + * {@code /com/example/artifactid} + */ + @Parameter(property = "contractsPath") + private String contractsPath; + + /** + * If {@code true} then JAR with contracts will be taken from local maven repository + */ + @Parameter(property = "contractsWorkOffline", defaultValue = "false") + private boolean contractsWorkOffline; + + private final AetherStubDownloaderFactory aetherStubDownloaderFactory; + + @Inject + public GenerateTestsMojo(AetherStubDownloaderFactory aetherStubDownloaderFactory) { + this.aetherStubDownloaderFactory = aetherStubDownloaderFactory; + } + public void execute() throws MojoExecutionException, MojoFailureException { if (this.skip || this.mavenTestSkip || this.skipTests) { if (this.skip) getLog().info("Skipping Spring Cloud Contract Verifier execution: spring.cloud.contract.verifier.skip=" + this.skip); @@ -115,7 +157,12 @@ public class GenerateTestsMojo extends AbstractMojo { getLog().info( "Generating server tests source code for Spring Cloud Contract Verifier contract verification"); final ContractVerifierConfigProperties config = new ContractVerifierConfigProperties(); - config.setContractsDslDir(this.contractsDirectory); + // download contracts, unzip them and pass as output directory + File contractsDirectory = new MavenContractsDownloader(this.project, this.contractDependency, + this.contractsPath, this.contractsRepositoryUrl, this.contractsWorkOffline, getLog(), + this.aetherStubDownloaderFactory, this.repoSession).downloadAndUnpackContractsIfRequired(config, this.contractsDirectory); + getLog().info("Directory with contract is present at [" + contractsDirectory + "]"); + config.setContractsDslDir(contractsDirectory); config.setGeneratedTestSourcesDir(this.generatedTestSourcesDir); config.setTargetFramework(this.testFramework); config.setTestMode(this.testMode); diff --git a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/MavenContractsDownloader.java b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/MavenContractsDownloader.java new file mode 100644 index 0000000000..26e0ed88da --- /dev/null +++ b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/main/java/org/springframework/cloud/contract/maven/verifier/MavenContractsDownloader.java @@ -0,0 +1,102 @@ +package org.springframework.cloud.contract.maven.verifier; + +import java.io.File; + +import org.apache.maven.model.Dependency; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.project.MavenProject; +import org.eclipse.aether.RepositorySystemSession; +import org.springframework.cloud.contract.maven.verifier.stubrunner.AetherStubDownloaderFactory; +import org.springframework.cloud.contract.stubrunner.AetherStubDownloader; +import org.springframework.cloud.contract.stubrunner.ContractDownloader; +import org.springframework.cloud.contract.stubrunner.StubConfiguration; +import org.springframework.cloud.contract.stubrunner.StubRunnerOptionsBuilder; +import org.springframework.cloud.contract.verifier.config.ContractVerifierConfigProperties; +import org.springframework.util.StringUtils; + +/** + * Downloads JAR with contracts + * + * @author Marcin Grzejszczak + * @since 1.0.0 + */ +class MavenContractsDownloader { + + private static final String LATEST_VERSION = "+"; + private static final String CONTRACTS_DIRECTORY_PROP = "CONTRACTS_DIRECTORY"; + + private final MavenProject project; + private final Dependency contractDependency; + private final String contractsPath; + private final String contractsRepositoryUrl; + private final boolean contractsWorkOffline; + private final Log log; + private final AetherStubDownloaderFactory aetherStubDownloaderFactory; + private final RepositorySystemSession repoSession; + + MavenContractsDownloader(MavenProject project, Dependency contractDependency, + String contractsPath, String contractsRepositoryUrl, + boolean contractsWorkOffline, Log log, + AetherStubDownloaderFactory aetherStubDownloaderFactory, + RepositorySystemSession repoSession) { + this.project = project; + this.contractDependency = contractDependency; + this.contractsPath = contractsPath; + this.contractsRepositoryUrl = contractsRepositoryUrl; + this.contractsWorkOffline = contractsWorkOffline; + this.log = log; + this.aetherStubDownloaderFactory = aetherStubDownloaderFactory; + this.repoSession = repoSession; + } + + File downloadAndUnpackContractsIfRequired(ContractVerifierConfigProperties config, File defaultContractsDir) { + String contractsDirFromProp = this.project.getProperties().getProperty(CONTRACTS_DIRECTORY_PROP); + File downloadedContractsDir = StringUtils.hasText(contractsDirFromProp) ? + new File(contractsDirFromProp) : null; + // reuse downloaded contracts from another mojo + if (downloadedContractsDir != null && downloadedContractsDir.exists()) { + this.log.info("Another mojo has downloaded the contracts - will reuse them from [" + downloadedContractsDir + "]"); + contractDownloader().updatePropertiesWithInclusion(downloadedContractsDir, config); + return downloadedContractsDir; + } else if (shouldDownloadContracts()) { + this.log.info("Download dependency is provided - will download contract jars"); + File downloadedContracts = contractDownloader().unpackedDownloadedContracts(config); + this.project.getProperties().setProperty(CONTRACTS_DIRECTORY_PROP, downloadedContracts.getAbsolutePath()); + return downloadedContracts; + } + this.log.info("Will use contracts provided in the folder [" + defaultContractsDir + "]"); + return defaultContractsDir; + } + + private boolean shouldDownloadContracts() { + return this.contractDependency != null && StringUtils.hasText(this.contractDependency.getArtifactId()); + } + + private ContractDownloader contractDownloader() { + return new ContractDownloader(stubDownloader(), stubConfiguration(), + this.contractsPath, this.project.getGroupId(), this.project.getArtifactId()); + } + + private AetherStubDownloader stubDownloader() { + if (StringUtils.hasText(this.contractsRepositoryUrl) || this.contractsWorkOffline) { + this.log.info("Will download contracts from [" + this.contractsRepositoryUrl + "]. " + + "Work offline switch equals to [" + this.contractsWorkOffline + "]"); + return new AetherStubDownloader( + new StubRunnerOptionsBuilder() + .withStubRepositoryRoot(this.contractsRepositoryUrl) + .withWorkOffline(this.contractsWorkOffline) + .build()); + } + this.log.info("Will download contracts using current build's Maven repository setup"); + return this.aetherStubDownloaderFactory.build(this.repoSession); + } + + private StubConfiguration stubConfiguration() { + String groupId = this.contractDependency.getGroupId(); + String artifactId = this.contractDependency.getArtifactId(); + String version = StringUtils.hasText(this.contractDependency.getVersion()) ? + this.contractDependency.getVersion() : LATEST_VERSION; + String classifier = this.contractDependency.getClassifier(); + return new StubConfiguration(groupId, artifactId, version, classifier); + } +} diff --git a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/java/org/springframework/cloud/contract/maven/verifier/PluginUnitTest.java b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/java/org/springframework/cloud/contract/maven/verifier/PluginUnitTest.java index 24a5f96b9e..4ecceb79b4 100644 --- a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/java/org/springframework/cloud/contract/maven/verifier/PluginUnitTest.java +++ b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/java/org/springframework/cloud/contract/maven/verifier/PluginUnitTest.java @@ -134,5 +134,32 @@ public class PluginUnitTest { assertFilesPresent(basedir, "target/sample-project-0.1-foo.jar"); } + @Test + public void shouldGenerateStubsByDownloadingContractsFromARepo() throws Exception { + File basedir = this.resources.getBasedir("basic-remote-contracts"); + this.maven.executeMojo(basedir, "convert", newParameter("contractsRepositoryUrl", "file://" + PluginUnitTest.class.getClassLoader().getResource("m2repo/repository").getFile())); + assertFilesPresent(basedir, "target/stubs/mappings/com/example/server/client1/contracts/shouldMarkClientAsFraud.json"); + } + + @Test + public void shouldGenerateStubsByDownloadingContractsFromARepoWhenCustomPathIsProvided() throws Exception { + File basedir = this.resources.getBasedir("complex-remote-contracts"); + this.maven.executeMojo(basedir, "convert", newParameter("contractsRepositoryUrl", "file://" + PluginUnitTest.class.getClassLoader().getResource("m2repo/repository").getFile())); + assertFilesPresent(basedir, "target/stubs/mappings/com/example/server/client1/contracts/shouldMarkClientAsFraud.json"); + } + + @Test + public void shouldGenerateTestsByDownloadingContractsFromARepo() throws Exception { + File basedir = this.resources.getBasedir("basic-remote-contracts"); + this.maven.executeMojo(basedir, "generateTests", newParameter("contractsRepositoryUrl", "file://" + PluginUnitTest.class.getClassLoader().getResource("m2repo/repository").getFile())); + assertFilesPresent(basedir, "target/generated-test-sources/contracts/org/springframework/cloud/contract/verifier/tests/com/example/server/client1/ContractsTest.java"); + } + + @Test + public void shouldGenerateTestsByDownloadingContractsFromARepoWhenCustomPathIsProvided() throws Exception { + File basedir = this.resources.getBasedir("complex-remote-contracts"); + this.maven.executeMojo(basedir, "generateTests", newParameter("contractsRepositoryUrl", "file://" + PluginUnitTest.class.getClassLoader().getResource("m2repo/repository").getFile())); + assertFilesPresent(basedir, "target/generated-test-sources/contracts/org/springframework/cloud/contract/verifier/tests/com/example/server/client1/ContractsTest.java"); + } } diff --git a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/projects/basic-remote-contracts/pom-with-repo.xml b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/projects/basic-remote-contracts/pom-with-repo.xml new file mode 100644 index 0000000000..9efa5fd96e --- /dev/null +++ b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/projects/basic-remote-contracts/pom-with-repo.xml @@ -0,0 +1,46 @@ + + + + 4.0.0 + + com.example + server + 0.1.BUILD-SNAPSHOT + + + + + + org.springframework.cloud + spring-cloud-contract-maven-plugin + + http://link/to/your/nexus/or/artifactory/or/sth + + com.example.standalone + contracts + + + + + + + + \ No newline at end of file diff --git a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/projects/basic-remote-contracts/pom.xml b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/projects/basic-remote-contracts/pom.xml new file mode 100644 index 0000000000..02a87e7a01 --- /dev/null +++ b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/projects/basic-remote-contracts/pom.xml @@ -0,0 +1,43 @@ + + + + 4.0.0 + + com.example + server + 0.1.BUILD-SNAPSHOT + + + + + org.springframework.cloud + spring-cloud-contract-maven-plugin + + + com.example + contracts + + + + + + + \ No newline at end of file diff --git a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/projects/complex-remote-contracts/pom.xml b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/projects/complex-remote-contracts/pom.xml new file mode 100644 index 0000000000..19b4b2ef8c --- /dev/null +++ b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/projects/complex-remote-contracts/pom.xml @@ -0,0 +1,46 @@ + + + + 4.0.0 + + com.example + someartifact + 0.1.BUILD-SNAPSHOT + + + + + org.springframework.cloud + spring-cloud-contract-maven-plugin + 1.0.0.BUILD-SNAPSHOT + + com/example/server + + com.example + contracts + + + + + + + + + \ No newline at end of file diff --git a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/logback.xml b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/logback.xml new file mode 100644 index 0000000000..df6a2daa72 --- /dev/null +++ b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/logback.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/m2repo/repository/com/example/contracts/0.0.1-SNAPSHOT/contracts-0.0.1-SNAPSHOT.jar b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/m2repo/repository/com/example/contracts/0.0.1-SNAPSHOT/contracts-0.0.1-SNAPSHOT.jar new file mode 100644 index 0000000000000000000000000000000000000000..4e3d44862b05ee796bb29c1eb190286bafe12793 GIT binary patch literal 2995 zcmWIWW@h1HVBp|j&|{wF!2kqIAOZ+Df!NnI#8KDN&rP41Apk|;rh2A#(m(~0KrDi+ z(AUw=)6F$FM9 zDNZdaOD!VPFlp?DCFf+O=9L(dX{aiGLzDCKN{SMbONt2^4+>5Wa4;_E&yrdN<8!?C1txyEd*OPIq&u#AY6x-Fe*n3LrVwS~i)v)h; z5z;!B#m#<`uJ)pBcbt@@tb6Q=9_}<`ll=N{uH%#Kk*>Az4%?bSm6o|&cyTza@tmgd z@mA;8@24E>ckvBb;>__t`EtbWmi*wIlV3J#ObuoTtXg#GL|)#Xb{@X#uPcjhimOL> zbNtHj`}pY#D=04_LhAFP6DWp{_50(_G0T=c?bbW5BO2HF+9ZF4_N6&nQfEe| zC(k<T?8iKWEk!P<#|{)Y@iTIb(fpCL7wx79Epq(WI@KIftCw)R`G+c#c3w{7;M z)c<$4-FETZ!teWT@7bNtYtC}|da`sBEn`{eWyL2xQNMWNh8MmQm}?3q{xW&&`l7u5 zi^PikZ&opTGKTv3%QL=b^|%_w9$?5Xc&uXU-50mbxF^m$=g}>$*xR`5?vttQ?(4Oy zdR_Q0>o6`0Q&2oyToX7|Mf*VZu6Yd{cDCPDCC-&ywODbtuD(H~L#X9>pZaz4%eQy` zj$CltRBlU?qXQJLBcsn)3?p-!q&b(2~=1823Rp(2t$$pvmx2ow?_dKrkCFgg0KAUs0Z&rwlsE}{dgChrDywcnj<=y=9B9JTD4tZ~fmZ|;vC#w^7h z_JTW#uT=cp`%7PGpW>tVTluf<{P#Xzk$>$vukG@(eZYc*kx7IZcLfW~D?lK?@YWGT z!>e0#ZRpi4NHGX3Y19OgaBWDHFUSNrki|fNUQ4ntNPxvb79Vgd1F|7CE-R$Q#i(s@ zo5X`+(iW&mh}xHQ6NOPs{Do#Bye1~yU}+SC-GMm{qFwKN3B+nt#@D{ p!+Jy|L!9-fMKrSY#jHeHPpH7g + + + + 4.0.0 + com.example + contracts + 0.0.1-SNAPSHOT + pom + diff --git a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/m2repo/repository/com/example/contracts/0.0.1-SNAPSHOT/maven-metadata-local.xml b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/m2repo/repository/com/example/contracts/0.0.1-SNAPSHOT/maven-metadata-local.xml new file mode 100644 index 0000000000..3b4511d3ba --- /dev/null +++ b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/m2repo/repository/com/example/contracts/0.0.1-SNAPSHOT/maven-metadata-local.xml @@ -0,0 +1,24 @@ + + + com.example + contracts + 0.0.1-SNAPSHOT + + + true + + 20160916125313 + + + jar + 0.0.1-SNAPSHOT + 20160916125313 + + + pom + 0.0.1-SNAPSHOT + 20160916125313 + + + + diff --git a/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/m2repo/repository/com/example/contracts/maven-metadata.xml b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/m2repo/repository/com/example/contracts/maven-metadata.xml new file mode 100644 index 0000000000..3d1d3547d5 --- /dev/null +++ b/spring-cloud-contract-tools/spring-cloud-contract-maven-plugin/src/test/resources/m2repo/repository/com/example/contracts/maven-metadata.xml @@ -0,0 +1,28 @@ + + + + + com.example + contracts + 0.0.1-SNAPSHOT + + + 0.0.1-SNAPSHOT + + 20160409062112 + + diff --git a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/config/ContractVerifierConfigProperties.groovy b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/config/ContractVerifierConfigProperties.groovy index 6e34af897e..9529f50910 100644 --- a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/config/ContractVerifierConfigProperties.groovy +++ b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/config/ContractVerifierConfigProperties.groovy @@ -102,4 +102,11 @@ class ContractVerifierConfigProperties { */ Boolean assertJsonSize = false + /** + * A regular expression that matches contracts. Especially useful when using a single JAR containing + * all the contracts in the system. In this case you'd like to take into consideration only some of them. + * Defaults to picking all files. + */ + String includedContracts = ".*" + } diff --git a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/file/ContractFileScanner.groovy b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/file/ContractFileScanner.groovy index e1af75de6a..f7d2b420d1 100755 --- a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/file/ContractFileScanner.groovy +++ b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/file/ContractFileScanner.groovy @@ -16,19 +16,17 @@ package org.springframework.cloud.contract.verifier.file +import com.google.common.collect.ArrayListMultimap +import com.google.common.collect.ListMultimap import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import org.apache.commons.lang3.SystemUtils import java.nio.file.FileSystem import java.nio.file.FileSystems import java.nio.file.Path import java.nio.file.PathMatcher import java.util.regex.Pattern - -import org.apache.commons.lang3.SystemUtils - -import com.google.common.collect.ArrayListMultimap -import com.google.common.collect.ListMultimap - /** * Scans the provided file path for the DSLs. There's a possibility to provide * inclusion and exclusion filters. @@ -38,6 +36,7 @@ import com.google.common.collect.ListMultimap * @since 1.0.0 */ @CompileStatic +@Slf4j class ContractFileScanner { private static final String MATCH_PREFIX = "glob:" @@ -45,11 +44,13 @@ class ContractFileScanner { private final File baseDir private final Set excludeMatchers private final Set ignoreMatchers + private final String includeMatcher - ContractFileScanner(File baseDir, Set excluded, Set ignored) { + ContractFileScanner(File baseDir, Set excluded, Set ignored, String includeMatcher = "") { this.baseDir = baseDir - excludeMatchers = processPatterns(excluded ?: [] as Set) - ignoreMatchers = processPatterns(ignored ?: [] as Set) + this.excludeMatchers = processPatterns(excluded ?: [] as Set) + this.ignoreMatchers = processPatterns(ignored ?: [] as Set) + this.includeMatcher = includeMatcher } private Set processPatterns(Set patterns) { @@ -79,8 +80,11 @@ class ContractFileScanner { return; } files.sort().eachWithIndex { File file, int index -> - if (!matchesPattern(file, excludeMatchers)) { - if (isContractFile(file)) { + boolean excluded = matchesPattern(file, excludeMatchers) + if (!excluded) { + boolean contractFile = isContractFile(file); + boolean included = includeMatcher ? file.absolutePath.matches(includeMatcher) : true + if (contractFile && included) { Path path = file.toPath() Integer order = null if (hasScenarioFilenamePattern(path)) { @@ -89,6 +93,13 @@ class ContractFileScanner { result.put(file.parentFile.toPath(), new ContractMetadata(path, matchesPattern(file, ignoreMatchers), files.size(), order)) } else { appendRecursively(file, result) + if (log.isDebugEnabled()) { + log.debug("File [$file] is ignored. Is a contract file? [$contractFile]. Should be included by pattern? [$included]") + } + } + } else { + if (log.isDebugEnabled()) { + log.debug("File [$file] is ignored. Should be excluded? [$excluded]") } } } @@ -98,8 +109,8 @@ class ContractFileScanner { return SCENARIO_STEP_FILENAME_PATTERN.matcher(path.fileName.toString()).matches() } - private boolean matchesPattern(File file, Set excludeMatchers) { - for (PathMatcher matcher : excludeMatchers) { + private boolean matchesPattern(File file, Set matchers) { + for (PathMatcher matcher : matchers) { if (matcher.matches(file.toPath())) { return true; }