diff --git a/docker/spring-cloud-contract-docker/build_adocs.sh b/docker/spring-cloud-contract-docker/build_adocs.sh new file mode 100755 index 0000000000..2b337b3d31 --- /dev/null +++ b/docker/spring-cloud-contract-docker/build_adocs.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +set -e + +CURRENT_DIR="$( pwd )" +ADOC_OUTPUT_DIR="${CURRENT_DIR}/target/adoc/" +pushd project + mkdir -p "${ADOC_OUTPUT_DIR}" + ./gradlew dumpAllProps + cp "$( pwd )/build/props.adoc" "${ADOC_OUTPUT_DIR}/" + cp "$( pwd )/build/appProps.adoc" "${ADOC_OUTPUT_DIR}/" +popd diff --git a/docker/spring-cloud-contract-docker/get_dependencies.sh b/docker/spring-cloud-contract-docker/get_dependencies.sh index ac35f0173e..1eabd3a1ea 100755 --- a/docker/spring-cloud-contract-docker/get_dependencies.sh +++ b/docker/spring-cloud-contract-docker/get_dependencies.sh @@ -8,15 +8,20 @@ GRADLE_WRAPPER_DIR="${HOME}/.gradle/wrapper/dists/${GRADLE_BIN_DIR}" CURRENT_DIR="$( pwd )" GRADLE_OUTPUT_DIR="${CURRENT_DIR}/target/gradle_dependencies/" pushd project -rm -rf .gradle -./gradlew wrapper --gradle-version "${WRAPPER_VERSION}" -./gradlew clean resolveDependencies build -g "${GRADLE_OUTPUT_DIR}" -x copyOutput || echo "Expected to fail the build" -if [ -d "${GRADLE_WRAPPER_DIR}" ]; then - echo "Copying Gradle Wrapper version [${WRAPPER_VERSION}]" - mkdir -p "${GRADLE_OUTPUT_DIR}/wrapper/dists/" - cp -r "${GRADLE_WRAPPER_DIR}" "${GRADLE_OUTPUT_DIR}/wrapper/dists/" -else - echo "Gradle Wrapper [${GRADLE_WRAPPER_DIR}] not found. Will not copy it" -fi -rm -rf build + rm -rf .gradle + ./gradlew wrapper --gradle-version "${WRAPPER_VERSION}" + ./gradlew clean resolveDependencies build -g "${GRADLE_OUTPUT_DIR}" -x copyOutput || echo "Expected to fail the build" + if [ -d "${GRADLE_WRAPPER_DIR}" ]; then + echo "Copying Gradle Wrapper version [${WRAPPER_VERSION}]" + mkdir -p "${GRADLE_OUTPUT_DIR}/wrapper/dists/" + cp -r "${GRADLE_WRAPPER_DIR}" "${GRADLE_OUTPUT_DIR}/wrapper/dists/" + else + echo "Gradle Wrapper [${GRADLE_WRAPPER_DIR}] not found. Will not copy it" + fi popd + +./build_adocs.sh + +pushd project + rm -rf build +popd \ No newline at end of file diff --git a/docker/spring-cloud-contract-docker/project/README.adoc b/docker/spring-cloud-contract-docker/project/README.adoc new file mode 100644 index 0000000000..e03da008be --- /dev/null +++ b/docker/spring-cloud-contract-docker/project/README.adoc @@ -0,0 +1,17 @@ +# Spring Cloud Contract Verifier Docker Project + +## Developer tips + +In order to use a new environment variable inside the Gradle script, you need to add it to the `envVars` map together with a description and a default value. Otherwise, any attempt to resolve such an environment variable will lead to an exception being thrown. + +If you're referencing any environment variables from inside the Java code please follow the following convention. + +```java +/** + * Some description. + **/ +@Value("${ENV_VAR_NAME:defaultValue}") +String envVar; +``` + +If you provide Javadocs, we will automatically parse any `@Value` annotated fields and build a table of environment variables with description and default values. \ No newline at end of file diff --git a/docker/spring-cloud-contract-docker/project/build.gradle b/docker/spring-cloud-contract-docker/project/build.gradle index fa867456af..8e71206e5f 100644 --- a/docker/spring-cloud-contract-docker/project/build.gradle +++ b/docker/spring-cloud-contract-docker/project/build.gradle @@ -1,3 +1,5 @@ +import contracts.DocsFromSources + buildscript { repositories { mavenLocal() @@ -10,13 +12,39 @@ buildscript { } dependencies { - classpath "io.spring.gradle:dependency-management-plugin:1.0.8.RELEASE" + classpath "org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}" classpath "org.springframework.cloud:spring-cloud-contract-gradle-plugin:${verifierVersion}" } } -group = getProp("PROJECT_GROUP") ?: 'com.example' -version = getProp("PROJECT_VERSION") ?: '0.0.1-SNAPSHOT' +Map envVars = [ + PROJECT_GROUP: new EnvVar("Your project's group ID", "com.example"), + PROJECT_VERSION: new EnvVar("Your project's version", "0.0.1-SNAPSHOT"), + PROJECT_NAME: new EnvVar("Your project's artifact id", "example"), + STANDALONE_PROTOCOL: new EnvVar("For standalone version, which additional protocol should be added", ""), + PRODUCER_STUBS_CLASSIFIER: new EnvVar("Archive classifier used for generated producer stubs", "stubs"), + FAIL_ON_NO_CONTRACTS: new EnvVar("Should the build fail if there are no contracts present?", false), + REPO_WITH_BINARIES_URL: new EnvVar("URL of your Artifact Manager (defaults to the default URL of https://jfrog.com/artifactory/[Artifactory] when running locally)", "http://localhost:8081/artifactory/libs-release-local"), + REPO_WITH_BINARIES_USERNAME: new EnvVar("(optional) Username when the Artifact Manager is secured", "admin"), + REPO_WITH_BINARIES_PASSWORD: new EnvVar("(optional) Password when the Artifact Manager is secured", "password"), + PUBLISH_ARTIFACTS: new EnvVar("If set to `true`, publishes the artifact to binary storage", "true"), + PUBLISH_ARTIFACTS_OFFLINE: new EnvVar("If set to `true`, publishes the artifacts to local m2", "false"), + EXTERNAL_CONTRACTS_GROUP_ID: new EnvVar("Group ID of the project with contracts", "com.example"), + EXTERNAL_CONTRACTS_ARTIFACT_ID: new EnvVar("Artifact ID of the project with contracts", ""), + EXTERNAL_CONTRACTS_CLASSIFIER: new EnvVar("Classifier of the project with contracts", ""), + EXTERNAL_CONTRACTS_VERSION: new EnvVar("Version of the project with contracts. Defautls to an equivalent of picking the latest", "+"), + EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_URL: new EnvVar("URL of your Artifact Manager. It defaults to the value of `REPO_WITH_BINARIES_URL` environment variable and if that is not set, it defaults to `http://localhost:8081/artifactory/libs-release-local`", ""), + EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_USERNAME: new EnvVar("(optional) Username if the `EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_URL` requires authentication. It defaults to `REPO_WITH_BINARIES_USERNAME`. If that is not set, it defaults to `admin", ""), + EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_PASSWORD: new EnvVar("(optional) Password if the `EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_URL` requires authentication. It defaults to `REPO_WITH_BINARIES_PASSWORD, If that is not set, it defaults to `password", ""), + EXTERNAL_CONTRACTS_PATH: new EnvVar("Path to contracts for the given project, inside the project with contracts. Defaults to slash-separated `EXTERNAL_CONTRACTS_GROUP_ID` concatenated with `/` and `EXTERNAL_CONTRACTS_ARTIFACT_ID. For example,\n" + + "for group id `cat-server-side.dog` and artifact ID `fish`, would result in `cat/dog/fish` for the contracts path.", ""), + EXTERNAL_CONTRACTS_WORK_OFFLINE: new EnvVar("If set to `true`, retrieves the artifact with contracts from the container's `.m2`. Mount your local `.m2` as a volume available at the container's `/root/.m2` path", "false"), + PUBLISH_STUBS_TO_SCM: new EnvVar("If set to `true` will run the task to publish stubs to scm", false), + MESSAGING_TYPE: new EnvVar("Type of messaging. Can be either [rabbit] or [kafka].", ""), +] + +group = getProp(envVars, "PROJECT_GROUP") ?: 'com.example' +version = getProp(envVars, "PROJECT_VERSION") ?: '0.0.1-SNAPSHOT' repositories { mavenLocal() @@ -29,21 +57,69 @@ repositories { } apply plugin: 'groovy' -apply plugin: 'io.spring.dependency-management' +apply plugin: "io.spring.dependency-management" +apply plugin: "org.springframework.boot" apply plugin: 'spring-cloud-contract' apply plugin: 'maven-publish' -dependencyManagement { - imports { - mavenBom "org.springframework.cloud:spring-cloud-contract-dependencies:${verifierVersion}" +bootJar.enabled = false + +class EnvVar { + final Object defaultValue + final String description + + EnvVar(String description, Object defaultValue) { + this.defaultValue = defaultValue + this.description = description + } + + boolean equals(o) { + if (this.is(o)) { + return true + } + if (getClass() != o.class) { + return false + } + EnvVar envVar = (EnvVar) o + if (defaultValue != envVar.defaultValue) { + return false + } + if (description != envVar.description) { + return false + } + return true + } + + int hashCode() { + int result + result = (defaultValue != null ? defaultValue.hashCode() : 0) + result = 31 * result + (description != null ? description.hashCode() : 0) + return result } } dependencies { + testImplementation(platform("org.springframework.cloud:spring-cloud-contract-dependencies:${verifierVersion}")) + testImplementation(platform("org.apache.camel.springboot:camel-spring-boot-dependencies:${camelVersion}")) + + testImplementation("org.springframework.boot:spring-boot-starter-web") testImplementation("org.springframework.cloud:spring-cloud-starter-contract-verifier") + testImplementation("org.springframework.amqp:spring-rabbit") + testImplementation("org.apache.camel.springboot:camel-spring-boot-starter") + testImplementation("org.apache.camel.springboot:camel-kafka-starter") + testImplementation("org.apache.camel.springboot:camel-rabbitmq-starter") + if (getProp(envVars, "STANDALONE_PROTOCOL")) { + testImplementation("org.apache.camel.springboot:camel-${getProp(envVars, "STANDALONE_PROTOCOL")}-starter") + } } test { + useJUnitPlatform() + if (getProp(envVars, "MESSAGING_TYPE") != "") { + systemProperty("spring.profiles.active", "messagingtype") + } else if (getProp(envVars, "STANDALONE_PROTOCOL") != "") { + systemProperty("spring.profiles.active", "standalone") + } testLogging { exceptionFormat = 'full' afterSuite { desc, result -> @@ -58,32 +134,33 @@ test { } contracts { - baseClassForTests = "contracts.RestBase" + baseClassForTests = "contracts.ContractTestsBase" testMode = "EXPLICIT" - stubsSuffix = getProp("PRODUCER_STUBS_CLASSIFIER") ?: "stubs" - if (getProp("EXTERNAL_CONTRACTS_ARTIFACT_ID")) { + stubsSuffix = getProp(envVars, "PRODUCER_STUBS_CLASSIFIER") ?: "stubs" + failOnNoContracts = getProp(envVars, "FAIL_ON_NO_CONTRACTS") ?: false + if (getProp(envVars, "EXTERNAL_CONTRACTS_ARTIFACT_ID")) { logger. - lifecycle("Will use an artifact with contracts [${getProp("EXTERNAL_CONTRACTS_GROUP_ID")}:${getProp("EXTERNAL_CONTRACTS_ARTIFACT_ID")}]") + lifecycle("Will use an artifact with contracts [${getProp(envVars, "EXTERNAL_CONTRACTS_GROUP_ID")}:${getProp(envVars, "EXTERNAL_CONTRACTS_ARTIFACT_ID")}]") // tests - contracts from an artifact - contractsPath = getProp("EXTERNAL_CONTRACTS_PATH") ?: "" - if (Boolean.parseBoolean(getProp("EXTERNAL_CONTRACTS_WORK_OFFLINE")) == false) { + contractsPath = getProp(envVars, "EXTERNAL_CONTRACTS_PATH") ?: "" + if (!Boolean.parseBoolean(getProp(envVars, "EXTERNAL_CONTRACTS_WORK_OFFLINE").toString())) { contractRepository { - repositoryUrl = getProp('EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_URL') ?: - getProp('REPO_WITH_BINARIES_URL') ?: 'http://localhost:8081/artifactory/libs-release-local' - username = getProp('EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_USERNAME') ?: - getProp('REPO_WITH_BINARIES_USERNAME') ?: 'admin' - password = getProp('EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_PASSWORD') ?: - getProp('REPO_WITH_BINARIES_PASSWORD') ?: 'password' + repositoryUrl = getProp(envVars, 'EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_URL') ?: + getProp(envVars, 'REPO_WITH_BINARIES_URL') ?: 'http://localhost:8081/artifactory/libs-release-local' + username = getProp(envVars, 'EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_USERNAME') ?: + getProp(envVars, 'REPO_WITH_BINARIES_USERNAME') ?: 'admin' + password = getProp(envVars, 'EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_PASSWORD') ?: + getProp(envVars, 'REPO_WITH_BINARIES_PASSWORD') ?: 'password' } } contractDependency { - groupId = getProp("EXTERNAL_CONTRACTS_GROUP_ID") ?: "com.example" - artifactId = getProp("EXTERNAL_CONTRACTS_ARTIFACT_ID") - delegate.classifier = getProp("EXTERNAL_CONTRACTS_CLASSIFIER") ?: "" - delegate.version = getProp("EXTERNAL_CONTRACTS_VERSION") ?: "+" + groupId = getProp(envVars, "EXTERNAL_CONTRACTS_GROUP_ID") ?: "com.example" + artifactId = getProp(envVars, "EXTERNAL_CONTRACTS_ARTIFACT_ID") + delegate.classifier = getProp(envVars, "EXTERNAL_CONTRACTS_CLASSIFIER") ?: "" + delegate.version = getProp(envVars, "EXTERNAL_CONTRACTS_VERSION") ?: "+" } contractsMode = Boolean. - parseBoolean(getProp("EXTERNAL_CONTRACTS_WORK_OFFLINE")) ? "LOCAL" : "REMOTE" + parseBoolean(getProp(envVars, "EXTERNAL_CONTRACTS_WORK_OFFLINE").toString()) ? "LOCAL" : "REMOTE" } else { logger.lifecycle("Will use contracts from the mounted [/contracts] folder") @@ -107,16 +184,15 @@ task copyOutput(type: Copy) { test { finalizedBy("copyOutput") - useJUnitPlatform() } publishing { repositories { maven { - url getProp('REPO_WITH_BINARIES_URL') ?: 'http://localhost:8081/artifactory/libs-release-local' + url getProp(envVars, 'REPO_WITH_BINARIES_URL') ?: 'http://localhost:8081/artifactory/libs-release-local' credentials { - username getProp('REPO_WITH_BINARIES_USERNAME') ?: 'admin' - password getProp('REPO_WITH_BINARIES_PASSWORD') ?: 'password' + username getProp(envVars, 'REPO_WITH_BINARIES_USERNAME').toString() ?: 'admin' + password getProp(envVars, 'REPO_WITH_BINARIES_PASSWORD').toString() ?: 'password' } } } @@ -125,8 +201,8 @@ publishing { } // explicitly disable artifacts publication -boolean publishEnabled = Boolean.parseBoolean(getProp("PUBLISH_ARTIFACTS") ?: "true") -boolean publishOffline = Boolean.parseBoolean(getProp("PUBLISH_ARTIFACTS_OFFLINE") ?: "false") +boolean publishEnabled = Boolean.parseBoolean(getProp(envVars, "PUBLISH_ARTIFACTS").toString() ?: "true") +boolean publishOffline = Boolean.parseBoolean(getProp(envVars, "PUBLISH_ARTIFACTS_OFFLINE").toString() ?: "false") publish.setEnabled(publishEnabled) publishToMavenLocal.setEnabled(publishOffline) @@ -138,14 +214,17 @@ gradle.taskGraph.whenReady { graph -> findAll { it.name.startsWith("publish") && it.name.endsWith("ToMavenLocal") }*.setEnabled(publishOffline) } -if (Boolean.parseBoolean(getProp("PUBLISH_STUBS_TO_SCM"))) { +if (Boolean.parseBoolean(getProp(envVars, "PUBLISH_STUBS_TO_SCM").toString())) { publish.dependsOn("publishStubsToScm") } -String getProp(String propName) { +Object getProp(Map envVars, String propName) { + if (!envVars.containsKey(propName)) { + throw new IllegalStateException("You've referenced a property with name [${propName}] but it's not in the list of accepatble props ${envVars.keySet()}") + } return hasProperty(propName) ? (getProperty(propName) ?: System.properties[propName]) : System.properties[propName] ?: - System.getenv(propName) + System.getenv(propName) ?: envVars.get(propName)?.defaultValue } task resolveDependencies { @@ -157,8 +236,27 @@ task resolveDependencies { config.files } catch (e) { - project.logger.info e.message // some cannot be resolved, silentlyish skip them + project.logger.info e.message // some cannot be resolved, silently skip them } } } } + +task dumpAllProps() { + doLast { + // TODO: Parse the java code for env vars + File output = new File("build", "props.adoc") + if (!output.exists()) { + output.parentFile.mkdirs() + output.createNewFile() + } + String table = """\ +.Docker environment variables +|=== +|Name | Description | Default +""" + output.text = table + envVars.sort().collect { '|' + it.key + '|' + it.value.description + '|' + it.value.defaultValue }.join("\n") + "\n|===" + + new DocsFromSources(project).buildApplicationEnvVars() + } +} \ No newline at end of file diff --git a/docker/spring-cloud-contract-docker/project/buildSrc/build.gradle b/docker/spring-cloud-contract-docker/project/buildSrc/build.gradle new file mode 100644 index 0000000000..6136febf35 --- /dev/null +++ b/docker/spring-cloud-contract-docker/project/buildSrc/build.gradle @@ -0,0 +1,21 @@ +repositories { + mavenLocal() + mavenCentral() + maven { url "https://repo.spring.io/snapshot" } + maven { url "https://repo.spring.io/milestone" } + maven { url "https://repo.spring.io/release" } +} + +ext { + roasterVersion = "2.21.2.Final" + junit5Version = "5.6.2" +} + +dependencies { + implementation "org.jboss.forge.roaster:roaster-api:${roasterVersion}" + implementation "org.jboss.forge.roaster:roaster-jdt:${roasterVersion}" +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/docker/spring-cloud-contract-docker/project/buildSrc/src/main/java/contracts/DocsFromSources.java b/docker/spring-cloud-contract-docker/project/buildSrc/src/main/java/contracts/DocsFromSources.java new file mode 100644 index 0000000000..1d1e2aa168 --- /dev/null +++ b/docker/spring-cloud-contract-docker/project/buildSrc/src/main/java/contracts/DocsFromSources.java @@ -0,0 +1,160 @@ +/* + * Copyright 2013-2020 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 + * + * https://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. + */ + +package contracts; + +import java.io.File; +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.FileVisitor; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import org.gradle.api.Project; +import org.jboss.forge.roaster.Roaster; +import org.jboss.forge.roaster.model.JavaUnit; +import org.jboss.forge.roaster.model.source.AnnotationSource; +import org.jboss.forge.roaster.model.source.FieldSource; +import org.jboss.forge.roaster.model.source.JavaClassSource; +import org.jboss.forge.roaster.model.source.JavaDocSource; + +public class DocsFromSources { + + private static final String ADOC_HEADER = + ".Docker environment variables - read at runtime\n" + + "|===\n" + + "|Name | Description | Default\n"; + + private final Project project; + + public DocsFromSources(Project project) { + this.project = project; + } + + public void buildApplicationEnvVars() { + Path path = new File(rootDir(), sourcePath()).toPath(); + List envVars = new ArrayList<>(); + FileVisitor fv = new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + if (!file.toString().endsWith(".java")) { + info("Skipping [" + file.toString() + "]"); + return FileVisitResult.CONTINUE; + } + JavaUnit unit = Roaster.parseUnit(Files.newInputStream(file)); + JavaClassSource myClass = unit.getGoverningType(); + info("Checking [" + myClass.getName() + "]"); + List> fields = myClass.getFields(); + for (FieldSource field : fields) { + List> annotations = field.getAnnotations(); + for (AnnotationSource annotation : annotations) { + if ("org.springframework.beans.factory.annotation.Value".equals(annotation.getQualifiedName())) { + info("Field [" + field.getName() + "] has @Value annotation"); + JavaDocSource> javaDoc = field.getJavaDoc(); + String description = javaDoc.getFullText(); + // ${foo:asd} + String annotationValue = annotation.getStringValue(); + // foo:asd + annotationValue = annotationValue.substring(2, annotationValue.length() - 1); + String[] parsed = annotationValue.split(":"); + String defaultValue = ""; + String name = parsed[0]; + if (parsed.length == 2) { + defaultValue = parsed[1]; + } + envVars.add(new EnvVar(name, description, defaultValue)); + } + } + } + info("Found [" + envVars.size() + "] env var field entries"); + return FileVisitResult.CONTINUE; + } + }; + + try { + Files.walkFileTree(path, fv); + Path output = new File(rootDir(), "build/appProps.adoc").toPath(); + StringBuilder stringBuilder = new StringBuilder().append(ADOC_HEADER); + Collections.sort(envVars); + envVars.forEach(envVar -> stringBuilder.append(envVar.toString()).append("\n")); + stringBuilder.append("|==="); + Files.write(output, stringBuilder.toString().getBytes()); + } + catch (IOException e) { + throw new IllegalStateException(e); + } + } + + String sourcePath() { + return "src/test/java/contracts"; + } + + File rootDir() { + return project.getRootDir(); + } + + void info(String log) { + this.project.getLogger().info(log); + } +} + +class EnvVar implements Comparable { + final String name; + final String description; + final String defaultValue; + + EnvVar(String name, String description, String defaultValue) { + this.name = name; + this.description = description; + this.defaultValue = defaultValue; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + EnvVar envVar = (EnvVar) o; + return Objects.equals(name, envVar.name) && + Objects.equals(description, envVar.description) && + Objects.equals(defaultValue, envVar.defaultValue); + } + + @Override + public int hashCode() { + return Objects.hash(name, description, defaultValue); + } + + @Override + public int compareTo(EnvVar o) { + return name.compareTo(o.name); + } + + @Override + public String toString() { + return "|" + name + "|" + description + "|" + defaultValue; + } +} \ No newline at end of file diff --git a/docker/spring-cloud-contract-docker/project/gradle.properties b/docker/spring-cloud-contract-docker/project/gradle.properties index c012ec3ac7..5e024e9e38 100644 --- a/docker/spring-cloud-contract-docker/project/gradle.properties +++ b/docker/spring-cloud-contract-docker/project/gradle.properties @@ -1,2 +1,4 @@ org.gradle.daemon=false verifierVersion=3.0.0-SNAPSHOT +springBootVersion=2.4.0-SNAPSHOT +camelVersion=3.4.3 \ No newline at end of file diff --git a/docker/spring-cloud-contract-docker/project/src/test/java/contracts/ContractTestsBase.java b/docker/spring-cloud-contract-docker/project/src/test/java/contracts/ContractTestsBase.java new file mode 100644 index 0000000000..4b9db23140 --- /dev/null +++ b/docker/spring-cloud-contract-docker/project/src/test/java/contracts/ContractTestsBase.java @@ -0,0 +1,186 @@ +/* + * Copyright 2013-2020 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 + * + * https://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. + */ + +package contracts; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.cloud.contract.verifier.converter.YamlContract; +import org.springframework.cloud.contract.verifier.messaging.MessageVerifier; +import org.springframework.cloud.contract.verifier.messaging.amqp.AmqpMetadata; +import org.springframework.cloud.contract.verifier.messaging.boot.AutoConfigureMessageVerifier; +import org.springframework.cloud.contract.verifier.messaging.camel.StandaloneMetadata; +import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessageMetadata; +import org.springframework.cloud.contract.verifier.util.ContractVerifierUtil; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +/** + * @author Marcin Grzejszczak + */ +@SpringBootTest(classes = ContractTestsBase.Config.class, webEnvironment = SpringBootTest.WebEnvironment.NONE) +@AutoConfigureMessageVerifier +public abstract class ContractTestsBase { + + private static final Logger log = LoggerFactory.getLogger(ContractTestsBase.class); + + /** + * URL at which the application is running. + */ + @Value("${APPLICATION_BASE_URL}") + String url; + + /** + * Optional username to access the application. + */ + @Value("${APPLICATION_USERNAME:}") + String username; + + /** + * Optional password to access the application. + */ + @Value("${APPLICATION_PASSWORD:}") + String password; + + /** + * Timeout to connect to the application to trigger a message. + */ + @Value("${MESSAGING_TRIGGER_CONNECT_TIMEOUT:5000}") + Integer connectTimeout; + + /** + * Timeout to read the response from the application to trigger a message. + */ + @Value("${MESSAGING_TRIGGER_READ_TIMEOUT:5000}") + Integer readTimeout; + + /** + * Defines the messaging type when dealing with message based contracts. + */ + @Value("${MESSAGING_TYPE:}") + String messagingType; + + @Autowired + MessageVerifier messageVerifier; + + @BeforeEach + public void setup(TestInfo testInfo) { + RestAssured.baseURI = this.url; + if (StringUtils.hasText(this.username)) { + RestAssured.authentication = RestAssured.basic(this.username, this.password); + } + setupMessagingFromContract(testInfo); + } + + private void setupMessagingFromContract(TestInfo testInfo) { + try { + YamlContract contract = ContractVerifierUtil.contract(this, testInfo.getDisplayName()); + setupMessagingIfPresent(contract); + } catch (Exception e) { + log.warn("An exception occurred while trying to setup messaging from contract", e); + } + } + + private void setupMessagingIfPresent(YamlContract contract) { + if (contract.input == null && contract.outputMessage == null) { + return; + } + setupAmqpIfPresent(contract); + setupStandaloneIfPresent(contract); + } + + private void setupAmqpIfPresent(YamlContract contract) { + AmqpMetadata amqpMetadata = AmqpMetadata.fromMetadata(contract.metadata); + if (isMessagingType("rabbit") && hasDeclaredOutputQueue(amqpMetadata) || isMessagingType("kafka")) { + log.info("First will try to receive a message to setup the connection with the broker"); + if (contract.input != null && StringUtils.hasText(contract.input.messageFrom)) { + setupConnection(contract.input.messageFrom, contract); + } + if (contract.outputMessage != null && StringUtils.hasText(contract.outputMessage.sentTo)){ + setupConnection(contract.outputMessage.sentTo, contract); + } + } + } + + private void setupStandaloneIfPresent(YamlContract contract) { + StandaloneMetadata metadata = StandaloneMetadata.fromMetadata(contract.metadata); + if (StringUtils.hasText(metadata.getSetup().getOptions())) { + log.info("First will try to receive a message to setup the connection with the broker"); + setMessageType(contract, ContractVerifierMessageMetadata.MessageType.SETUP); + setupConnection(metadata.getSetup().getOptions(), contract); + } + } + + private void setMessageType(YamlContract contract, + ContractVerifierMessageMetadata.MessageType output) { + contract.metadata.put(ContractVerifierMessageMetadata.METADATA_KEY, + new ContractVerifierMessageMetadata(output)); + } + + private void setupConnection(String destination, YamlContract contract) { + if (StringUtils.isEmpty(destination)) { + return; + } + log.info("Setting up destination [{}]", destination); + this.messageVerifier.receive(destination, 100, TimeUnit.MILLISECONDS, contract); + } + + private boolean hasDeclaredOutputQueue(AmqpMetadata amqpMetadata) { + return StringUtils.hasText(amqpMetadata.getOutputMessage().getConnectToBroker().getDeclareQueueWithName()); + } + + private boolean isMessagingType(String rabbit) { + return rabbit.equalsIgnoreCase(this.messagingType); + } + + public void triggerMessage(String label) { + String url = this.url + "/springcloudcontract/" + label; + log.info("Will send a request to [{}] in order to trigger a message", url); + restTemplate().postForObject(url, "", String.class); + } + + private RestTemplate restTemplate() { + RestTemplateBuilder builder = new RestTemplateBuilder() + .setConnectTimeout(Duration.ofMillis(this.connectTimeout)) + .setReadTimeout(Duration.ofMillis(this.readTimeout)); + if (StringUtils.hasText(this.username)) { + builder = builder.basicAuthentication(this.username, this.password); + } + return builder.build(); + } + + @Configuration + @Import(MessagingAutoConfig.class) + @EnableAutoConfiguration + protected static class Config { + + } + +} diff --git a/docker/spring-cloud-contract-docker/project/src/test/java/contracts/MessagingAutoConfig.java b/docker/spring-cloud-contract-docker/project/src/test/java/contracts/MessagingAutoConfig.java new file mode 100644 index 0000000000..276afda509 --- /dev/null +++ b/docker/spring-cloud-contract-docker/project/src/test/java/contracts/MessagingAutoConfig.java @@ -0,0 +1,200 @@ +/* + * Copyright 2013-2020 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 + * + * https://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. + */ + +package contracts; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.apache.camel.ConsumerTemplate; +import org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.amqp.core.MessageProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cloud.contract.verifier.converter.YamlContract; +import org.springframework.cloud.contract.verifier.messaging.MessageVerifier; +import org.springframework.cloud.contract.verifier.messaging.amqp.AmqpMetadata; +import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessage; +import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessageMetadata; +import org.springframework.cloud.contract.verifier.messaging.internal.ContractVerifierMessaging; +import org.springframework.cloud.contract.verifier.messaging.kafka.KafkaMetadata; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.util.StringUtils; + +/** + * @author Marcin Grzejszczak + */ +@Configuration +@ConditionalOnProperty("MESSAGING_TYPE") +@Profile("messagingtype") +public class MessagingAutoConfig { + + /** + * Type of messaging. Can be either [rabbit] or [kafka]. + */ + @Value("${MESSAGING_TYPE:}") + String messagingType; + + /** + * For RabbitMQ - brokers addresses. + */ + @Value("${SPRING_RABBITMQ_ADDRESSES:}") + String springRabbitmqAddresses; + + /** + * For Kafka - brokers addresses. + */ + @Value("${SPRING_KAFKA_BOOTSTRAP_SERVERS:}") + String springKafkaBootstrapServers; + + @Bean + public ContractVerifierMessaging contractVerifierMessaging( + MessageVerifier exchange) { + return new ContractVerifierCamelHelper(exchange); + } + + @Bean + MessageVerifier manualMessageVerifier(ConsumerTemplate consumerTemplate) { + return new MessageVerifier() { + + private final Logger log = LoggerFactory.getLogger(MessageVerifier.class); + + @Override + public Message receive(String destination, long timeout, TimeUnit timeUnit, YamlContract yamlContract) { + String uri = messagingType() + "://" + destination + additionalOptions(yamlContract); + log.info("Camel URI [{}]", uri); + Exchange exchange = consumerTemplate.receive(uri, timeUnit.toMillis(timeout)); + if (exchange == null) { + return null; + } + return exchange.getMessage(); + } + + private String messagingType() { + if (messagingType.equalsIgnoreCase("kafka")) { + return "kafka"; + } + return "rabbitmq"; + } + + private String additionalOptions(YamlContract contract) { + if (contract == null) { + return ""; + } + if (messagingType.equalsIgnoreCase("kafka")) { + return setKafkaOpts(contract); + } + return setRabbitOpts(contract); + } + + private String setKafkaOpts(YamlContract contract) { + String opts = defaultOpts(contract); + KafkaMetadata metadata = KafkaMetadata.fromMetadata(contract.metadata); + ContractVerifierMessageMetadata messageMetadata = ContractVerifierMessageMetadata.fromMetadata(contract.metadata); + if (inputMessage(messageMetadata) && StringUtils.hasText(metadata.getInput().getConnectToBroker().getAdditionalOptions())) { + return opts + "&" + metadata.getInput().getConnectToBroker().getAdditionalOptions(); + } + else if (StringUtils.hasText(metadata.getOutputMessage().getConnectToBroker().getAdditionalOptions())) { + return opts + "&" + metadata.getOutputMessage().getConnectToBroker().getAdditionalOptions(); + } + return opts; + } + + private String defaultOpts(YamlContract contract) { + String consumerGroup = sameConsumerGroupForSameContract(contract); + return "?brokers=" + getRequiredProperty("SPRING_KAFKA_BOOTSTRAP_SERVERS", springKafkaBootstrapServers) + "&autoOffsetReset=latest&groupId=" + consumerGroup + "&shutdownTimeout=5"; + } + + private String sameConsumerGroupForSameContract(YamlContract contract) { + return contract.input.hashCode() + "_" + contract.outputMessage.hashCode(); + } + + private String setRabbitOpts(YamlContract contract) { + String opts = "?addresses=" + getRequiredProperty("SPRING_RABBITMQ_ADDRESSES", springRabbitmqAddresses); + AmqpMetadata metadata = AmqpMetadata.fromMetadata(contract.metadata); + ContractVerifierMessageMetadata messageMetadata = ContractVerifierMessageMetadata.fromMetadata(contract.metadata); + if (inputMessage(messageMetadata) && StringUtils.hasText(metadata.getInput().getConnectToBroker().getAdditionalOptions())) { + return opts + "&" + metadata.getInput().getConnectToBroker().getAdditionalOptions(); + } + else if (StringUtils.hasText(metadata.getOutputMessage().getConnectToBroker().getAdditionalOptions())) { + return opts + "&" + metadata.getOutputMessage().getConnectToBroker().getAdditionalOptions(); + } + return defaultOpts(opts, metadata, messageMetadata); + } + + private String getRequiredProperty(String name, String value) { + if (StringUtils.isEmpty(value)) { + throw new IllegalStateException("The property [" + name + "] must not be empty!"); + } + return value; + } + + private boolean inputMessage(ContractVerifierMessageMetadata messageMetadata) { + return messageMetadata.getMessageType() == ContractVerifierMessageMetadata.MessageType.INPUT; + } + + private String defaultOpts(String opts, AmqpMetadata amqpMetadata, ContractVerifierMessageMetadata messageMetadata) { + AmqpMetadata.ConnectToBroker connectToBroker = inputMessage(messageMetadata) ? amqpMetadata.getInput().getConnectToBroker() : amqpMetadata.getOutputMessage().getConnectToBroker(); + MessageProperties messageProperties = inputMessage(messageMetadata) ? amqpMetadata.getInput().getMessageProperties() : amqpMetadata.getOutputMessage().getMessageProperties(); + if (StringUtils.hasText(connectToBroker.getDeclareQueueWithName())) { + opts = opts + "&queue=" + connectToBroker.getDeclareQueueWithName(); + } + if (messageProperties != null && StringUtils.hasText(messageProperties.getReceivedRoutingKey())) { + opts = opts + "&routingKey=" + messageProperties.getReceivedRoutingKey(); + } + return opts; + } + + @Override + public Message receive(String destination, YamlContract yamlContract) { + return receive(destination, 5, TimeUnit.SECONDS, yamlContract); + } + + @Override + public void send(Message message, String destination, YamlContract yamlContract) { + throw new UnsupportedOperationException("Currently supports only receiving"); + } + + @Override + public void send(Object payload, Map headers, String destination, YamlContract yamlContract) { + throw new UnsupportedOperationException("Currently supports only receiving"); + } + }; + } + +} + +class ContractVerifierCamelHelper extends ContractVerifierMessaging { + + ContractVerifierCamelHelper(MessageVerifier exchange) { + super(exchange); + } + + @Override + protected ContractVerifierMessage convert(Message receive) { + if (receive == null) { + return null; + } + return new ContractVerifierMessage(receive.getBody(), receive.getHeaders()); + } + +} \ No newline at end of file diff --git a/docker/spring-cloud-contract-docker/project/src/test/java/contracts/RestBase.java b/docker/spring-cloud-contract-docker/project/src/test/java/contracts/RestBase.java deleted file mode 100644 index 43ba42682a..0000000000 --- a/docker/spring-cloud-contract-docker/project/src/test/java/contracts/RestBase.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2013-2020 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 - * - * https://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. - */ - -package contracts; - -import io.restassured.RestAssured; -import org.junit.jupiter.api.BeforeEach; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Configuration; -import org.springframework.util.StringUtils; - -/** - * @author Marcin Grzejszczak - */ -@SpringBootTest(classes = RestBase.Config.class, webEnvironment = SpringBootTest.WebEnvironment.NONE) -public abstract class RestBase { - - @Value("${APPLICATION_BASE_URL}") - String url; - - @Value("${APPLICATION_USERNAME:}") - String username; - - @Value("${APPLICATION_PASSWORD:}") - String password; - - @BeforeEach - public void setup() { - RestAssured.baseURI = this.url; - if (StringUtils.hasText(this.username)) { - RestAssured.authentication = RestAssured.basic(this.username, this.password); - } - } - - @Configuration - @EnableAutoConfiguration - protected static class Config { - - } - -} diff --git a/docker/spring-cloud-contract-docker/project/src/test/resources/META-INF/spring.factories b/docker/spring-cloud-contract-docker/project/src/test/resources/META-INF/spring.factories new file mode 100644 index 0000000000..6e51754b49 --- /dev/null +++ b/docker/spring-cloud-contract-docker/project/src/test/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Auto Configuration +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +contracts.MessagingAutoConfig \ No newline at end of file diff --git a/docker/spring-cloud-contract-docker/project/src/test/resources/application-messagingtype.yml b/docker/spring-cloud-contract-docker/project/src/test/resources/application-messagingtype.yml new file mode 100644 index 0000000000..462cdc5878 --- /dev/null +++ b/docker/spring-cloud-contract-docker/project/src/test/resources/application-messagingtype.yml @@ -0,0 +1 @@ +stubrunner.camel.enabled: false \ No newline at end of file diff --git a/docker/spring-cloud-contract-stub-runner-docker/run.sh b/docker/spring-cloud-contract-stub-runner-docker/run.sh index ba2f3fa70d..299fe3c2c8 100755 --- a/docker/spring-cloud-contract-stub-runner-docker/run.sh +++ b/docker/spring-cloud-contract-stub-runner-docker/run.sh @@ -14,4 +14,5 @@ if [[ "${MESSAGING_TYPE}" != "" ]]; then ADDITIONAL_OPTS="${ADDITIONAL_OPTS} --thin.profile=${MESSAGING_TYPE}" fi +echo "Please wait for the dependencies to be downloaded..." java -Djava.security.egd=file:/dev/./urandom -jar /stub-runner-boot.jar ${ADDITIONAL_OPTS} diff --git a/docs/pom.xml b/docs/pom.xml index abf3a96b9f..74d92f5ecf 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -44,7 +44,7 @@ maven-surefire-plugin - schema-generation + resource-generation generate-test-resources test @@ -77,6 +77,11 @@ ${jackson-module-jsonSchema.version} test + + org.springframework.amqp + spring-amqp + test + @@ -98,6 +103,19 @@ org.codehaus.mojo exec-maven-plugin + + + generate-docker-env-vars + generate-resources + + exec + + + ${maven.multiModuleProjectDirectory}/docker/spring-cloud-contract-docker + ./build_adocs.sh + + + org.asciidoctor diff --git a/docs/src/main/asciidoc/_project-features-contract.adoc b/docs/src/main/asciidoc/_project-features-contract.adoc index dce7260c16..521f8615d3 100644 --- a/docs/src/main/asciidoc/_project-features-contract.adoc +++ b/docs/src/main/asciidoc/_project-features-contract.adoc @@ -442,7 +442,7 @@ both for HTTP and messaging. ==== Metadata You can add `metadata` to your contract. Via the metadata you can pass in configuration to extensions. Below you can find -an example of using the `wiremock` key and value being WireMock's `StubMapping` object. Spring Cloud Contract is able to +an example of using the `wiremock` key. Its value is a map whose key is `stubMapping` and value being WireMock's `StubMapping` object. Spring Cloud Contract is able to patch parts of your generated stub mapping with your custom code. You may want to do that in order to add webhooks, custom delays or integrate with third party WireMock extensions. @@ -472,6 +472,8 @@ include::{contract_kotlin_spec_path}/src/test/kotlin/org/springframework/cloud/c ---- ==== +You can check the <> section for examples of what we support in the metadata section. + [[features-http]] == Contracts for HTTP @@ -2006,3 +2008,10 @@ name of `scenario1` and the three following steps: You can find more details about WireMock scenarios at https://wiremock.org/docs/stateful-behaviour/[https://wiremock.org/docs/stateful-behaviour/]. + +[[contract-metadata-examples]] +=== Contract Metadata Examples + +In the following sections you can find examples of the supported metadata entries. + +include::{project-root}/docs/target/metadata.adoc[indent=0] \ No newline at end of file diff --git a/docs/src/main/asciidoc/docker-project.adoc b/docs/src/main/asciidoc/docker-project.adoc index fa940bfdb6..8b5b51d8d7 100644 --- a/docs/src/main/asciidoc/docker-project.adoc +++ b/docs/src/main/asciidoc/docker-project.adoc @@ -76,54 +76,14 @@ The Docker image requires some environment variables to point to your running application, to the Artifact manager instance, and so on. The following list describes the environment variables: -- `PROJECT_GROUP`: Your project's group ID. Defaults to `com.example`. -- `PROJECT_VERSION`: Your project's version. Defaults to `0.0.1-SNAPSHOT`. -- `PROJECT_NAME`: Your project's artifact id. Defaults to `example`. -- `PRODUCER_STUBS_CLASSIFIER`: Archive classifier used for generated producer stubs. Defaults to `stubs`. -- `REPO_WITH_BINARIES_URL`: URL of your Artifact Manager. Defaults to `http://localhost:8081/artifactory/libs-release-local`, -which is the default URL of https://jfrog.com/artifactory/[Artifactory] when running locally. -- `REPO_WITH_BINARIES_USERNAME`: (optional) Username when the Artifact Manager is secured. Defaults to `admin`. -- `REPO_WITH_BINARIES_PASSWORD`: (optional) Password when the Artifact Manager is secured. Defaults to `password`. -- `PUBLISH_ARTIFACTS`: If set to `true`, publishes the artifact to binary storage. Defaults to `true`. -- `PUBLISH_ARTIFACTS_OFFLINE`: If set to `true`, publishes the artifacts to local `.m2`. Defaults to `false`. - -The following environment variables are used when contracts are in an external repository. To enable -this feature, you must set the `EXTERNAL_CONTRACTS_ARTIFACT_ID` environment variable. - -- `EXTERNAL_CONTRACTS_GROUP_ID`: Group ID of the project with contracts. Defaults to `com.example` -- `EXTERNAL_CONTRACTS_ARTIFACT_ID`: Artifact ID of the project with contracts. -- `EXTERNAL_CONTRACTS_CLASSIFIER`: Classifier of the project with contracts. Empty by default. -- `EXTERNAL_CONTRACTS_VERSION`: Version of the project with contracts. Defaults to `+`, equivalent to picking the latest. -- `EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_URL`: URL of your Artifact Manager. It defaults to -the value of `REPO_WITH_BINARIES_URL` environment variable. -If that is not set, it defaults to `http://localhost:8081/artifactory/libs-release-local`, -which is the default URL of https://jfrog.com/artifactory/[Artifactory] when running locally. -- `EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_USERNAME`: (optional) Username if the `EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_URL` -requires authentication. It defaults to `REPO_WITH_BINARIES_USERNAME`. If that is not set, it defaults to `admin`. -- `EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_PASSWORD`: (optional) Password if the `EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_URL` -requires authentication. It defaults to `REPO_WITH_BINARIES_PASSWORD`. If that is not set, it defaults to `password`. -- `EXTERNAL_CONTRACTS_PATH`: Path to contracts for the given project, inside the project with contracts. -Defaults to slash-separated `EXTERNAL_CONTRACTS_GROUP_ID` concatenated with `/` and `EXTERNAL_CONTRACTS_ARTIFACT_ID`. For example, -for group id `cat-server-side.dog` and artifact ID `fish`, would result in `cat/dog/fish` for the contracts path. -- `EXTERNAL_CONTRACTS_WORK_OFFLINE`; If set to `true`, retrieves the artifact with contracts -from the container's `.m2`. Mount your local `.m2` as a volume available at the container's `/root/.m2` path. - -CAUTION: You must not set both `EXTERNAL_CONTRACTS_WORK_OFFLINE` and `EXTERNAL_CONTRACTS_REPO_WITH_BINARIES_URL`. - -The following environment variables are used when running messaging based tests: - -- `MESSAGING_TYPE` - what type of messaging system are you using (currently supported are `rabbit`, `kafka`) +include::{project-root}/docker/spring-cloud-contract-docker/target/adoc/props.adoc[indent=0] The following environment variables are used when tests are run: -- `APPLICATION_BASE_URL`: URL against which tests should be run. -Remember that it has to be accessible from the Docker container (for example, `localhost` -does not work) -- `APPLICATION_USERNAME`: (optional) Username for basic authentication to your application. -- `APPLICATION_PASSWORD`: (optional) Password for basic authentication to your application. +include::{project-root}/docker/spring-cloud-contract-docker/target/adoc/appProps.adoc[indent=0] [[docker-example-of-usage]] -=== Example of Usage +=== Example of Usage via HTTP In this section, we explore a simple MVC application. To get started, clone the following git repository and cd to the resulting directory, by running the following commands: @@ -200,6 +160,216 @@ are run against the running application. http://localhost:8081/artifactory/libs-release-local/com/example/bookstore/0.0.1.RELEASE/. The stubs are at http://localhost:8081/artifactory/libs-release-local/com/example/bookstore/0.0.1.RELEASE/bookstore-0.0.1.RELEASE-stubs.jar. +[[docker-example-of-usage-messaging]] +=== Example of Usage via Messaging + +If you want to use Spring Cloud Contract with messaging via the Docker images (e.g. +in case of polyglot applications) then you'll have to have the following prerequisites met: + +* Middleware (e.g. RabbitMQ or Kafka) must be running before generating tests +* Your contract needs to call a method `triggerMessage(...)` with a `String` parameter that is equal to the contract's `label`. +* Your application needs to have a HTTP endpoint via which we can trigger a message +** That endpoint should not be available on production (could be enabled via an environment variable) + +[[docker-example-of-usage-messaging-contract]] +==== Example of a Messaging Contract + +The contract needs to call a `triggerMessage(...)` method. That method is already provided in the base class for all tests in the docker image and will send out a request to the HTTP endpoint on the producer side. Below you can find examples of such contracts. + +==== +[source,groovy,indent=0,subs="verbatim,attributes",role="primary"] +.Groovy +---- +import org.springframework.cloud.contract.spec.Contract + +Contract.make { + description 'Send a pong message in response to a ping message' + label 'ping_pong' + input { + // You have to provide the `triggerMessage` method with the `label` + // as a String parameter of the method + triggeredBy('triggerMessage("ping_pong")') + } + outputMessage { + sentTo('output') + body([ + message: 'pong' + ]) + } + metadata( + [amqp: + [ + outputMessage: [ + connectToBroker: [ + declareQueueWithName: "queue" + ], + messageProperties: [ + receivedRoutingKey: '#' + ] + ] + ] + ]) +} +---- + +[source,yml,indent=0,subs="verbatim,attributes",role="secondary"] +.YAML +---- +description: 'Send a pong message in response to a ping message' +label: 'ping_pong' +input: + # You have to provide the `triggerMessage` method with the `label` + # as a String parameter of the method + triggeredBy: 'triggerMessage("ping_pong")' +outputMessage: + sentTo: 'output' + body: + message: 'pong' +metadata: + amqp: + outputMessage: + connectToBroker: + declareQueueWithName: "queue" + messageProperties: + receivedRoutingKey: '#' +---- +==== + +[[docker-example-of-usage-messaging-endpoint]] +==== HTTP Endpoint to Trigger a Message + +Why is there need to develop such an endpoint? Spring Cloud Contract +would have to generate code in various languages (as it does in Java) to make it possible to trigger production +code that sends a message to a broker. If such code is not generated then we need to be able to trigger the message anyways, and the way to do it is to provide an HTTP endpoint that the user will prepare in the language of their choosing. + +The endpoint must have the following configuration: + +- URL: `/springcloudcontract/{label}` where `label` can be any text +- Method: `POST` +- Basing on the `label` will generate a message that will be sent to a given destination according to the contract definition + +Below you have an example of such an endpoint. If you're interested in +providing an example in your language don't hesitate to file an issue in +the https://github.com/spring-cloud/spring-cloud-contract/issues/new?assignees=&labels=&template=feature_request.md&title=New+Polyglot+Sample+of+a+HTTP+controller[Spring Cloud Contract repository at Github]. + +==== +[source,python,indent=0,subs="verbatim,attributes"] +.Python +---- +#!/usr/bin/env python + +from flask import Flask +from flask import jsonify +import pika +import os + +app = Flask(__name__) + +# Production code that sends a message to RabbitMQ +def send_message(cmd): + connection = pika.BlockingConnection(pika.ConnectionParameters(host='localhost')) + channel = connection.channel() + channel.basic_publish( + exchange='output', + routing_key='#', + body=cmd, + properties=pika.BasicProperties( + delivery_mode=2, # make message persistent + )) + connection.close() + return " [x] Sent via Rabbit: %s" % cmd + +# This should be ran in tests (shouldn't be publicly available) +if 'CONTRACT_TEST' in os.environ: + @app.route('/springcloudcontract/