Skip to content

Commit

Permalink
Introduction of "stubs per consumer" approach
Browse files Browse the repository at this point in the history
There are cases in which 2 consumers of the same endpoint want to have 2 different responses. Without this change we don't support it. We don't support that consumers may overlap with their contracts. The outcome of such overlapping is that the first matching stub will be registered in HTTP server stub and the other will be ignored.
With this change we're allowing such a thing to happen. Each consumer can set the `stubrunner.stubs-per-consumer` flag and the `spring.application.name` or `stubrunner.consumer-name` flag will be taken into consideration. If the consumer's name is present in the path of a stub / contract then its mapping / messaging contract will be reused in tests. If not then it will get ignored

fixes #224
  • Loading branch information
marcingrzejszczak committed May 4, 2017
1 parent d084140 commit 808684f
Show file tree
Hide file tree
Showing 17 changed files with 501 additions and 18 deletions.
95 changes: 94 additions & 1 deletion spring-cloud-contract-stub-runner/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,6 @@ for every registered WireMock server. Example for Stub Runner ids

Which you can reference in your code.


=== Stub Runner Spring Cloud

Stub Runner can integrate with Spring Cloud.
Expand Down Expand Up @@ -340,3 +339,97 @@ That way your deployed application can send requests to started WireMock servers
discovery. Most likely points 1-3 could be set by default in `application.yml` cause they are not
likely to change. That way you can provide only the list of stubs to download whenever you start
the Stub Runner Boot.

=== Stubs Per Consumer

There are cases in which 2 consumers of the same endpoint want to have 2 different responses.

TIP: This approach also allows you to immediately know which consumer is using which part of your API.
You can remove part of a response that your API produces and you can see which of your autogenerated tests
fails. If none fails then you can safely delete that part of the response cause nobody is using it.

Let's look at the following example for contract defined for the producer called `producer`.
There are 2 consumers: `foo-consumer` and `bar-consumer`.

*Consumer `foo-service`*

[source,groovy]
----
request {
url '/foo'
method GET()
}
response {
status 200
body(
foo: "foo"
}
}
----

*Consumer `bar-service`*

[source,groovy]
----
request {
url '/foo'
method GET()
}
response {
status 200
body(
bar: "bar"
}
}
----

You can't produce for the same request 2 different responses. That's why you can properly package the
contracts and then profit from the `stubsPerConsumer` feature.

On the producer side the consumers can have a folder that contains contracts related only to them.
By setting the `stubrunner.stubs-per-consumer` flag to `true` we no longer register all stubs but only those that
correspond to the consumer application's name. In other words we'll scan the path of every stub and
if it contains the subfolder with name of the consumer in the path only then will it get registered.

On the `foo` producer side the contracts would look like this

[source,bash]
----
.
└── contracts
├── bar-consumer
│   ├── bookReturnedForBar.groovy
│   └── shouldCallBar.groovy
└── foo-consumer
├── bookReturnedForFoo.groovy
└── shouldCallFoo.groovy
----

Being the `bar-consumer` consumer you can either set the `spring.application.name` or the `stubrunner.consumer-name` to `bar-consumer`
Or set the test as follows:

[source,groovy]
----
include::src/test/groovy/org/springframework/cloud/contract/stubrunner/spring/cloud/StubRunnerStubsPerConsumerSpec.groovy[tags=test]
...
}
----

Then only the stubs registered under a path that contains the `bar-consumer` in its name (i.e. those from the
`src/test/resources/contracts/bar-consumer/some/contracts/...` folder) will be allowed to be referenced.

Or set the consumer name explicitly

[source,groovy]
----
include::src/test/groovy/org/springframework/cloud/contract/stubrunner/spring/cloud/StubRunnerStubsPerConsumerWithConsumerNameSpec.groovy[tags=test]
...
}
----

Then only the stubs registered under a path that contains the `foo-consumer` in its name (i.e. those from the
`src/test/resources/contracts/foo-consumer/some/contracts/...` folder) will be allowed to be referenced.

You can check out https://github.com/spring-cloud/spring-cloud-contract/issues/224[issue 224] for more
information about the reasons behind this change.

Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ class StubRepository {
private final File path;
final List<WiremockMappingDescriptor> projectDescriptors;
final Collection<Contract> contracts;
private final StubRunnerOptions options;

public StubRepository(File repository) {
public StubRepository(File repository, StubRunnerOptions options) {
if (!repository.isDirectory()) {
throw new IllegalArgumentException(
"Missing descriptor repository under path [" + repository + "]");
}
this.path = repository;
this.options = options;
this.projectDescriptors = projectDescriptors();
this.contracts = contracts();
}
Expand Down Expand Up @@ -101,7 +103,7 @@ private List<WiremockMappingDescriptor> collectMappingDescriptors(
public FileVisitResult visitFile(Path path,
BasicFileAttributes attrs) throws IOException {
File file = path.toFile();
if (isMappingDescriptor(file)) {
if (isMappingDescriptor(file) && isStubPerConsumerPathMatching(file)) {
mappingDescriptors
.add(new WiremockMappingDescriptor(file));
}
Expand Down Expand Up @@ -129,7 +131,7 @@ private Collection<Contract> collectContractDescriptors(File descriptorsDirector
public FileVisitResult visitFile(Path path,
BasicFileAttributes attrs) throws IOException {
File file = path.toFile();
if (isContractDescriptor(file)) {
if (isContractDescriptor(file) && isStubPerConsumerPathMatching(file)) {
mappingDescriptors
.add(ContractVerifierDslConverter.convert(file));
}
Expand All @@ -147,6 +149,14 @@ private static boolean isMappingDescriptor(File file) {
return file.isFile() && file.getName().endsWith(".json");
}

private boolean isStubPerConsumerPathMatching(File file) {
if (!this.options.isStubsPerConsumer()) {
return true;
}
String consumerName = this.options.getConsumerName();
return file.getAbsolutePath().contains(File.separator + consumerName + File.separator);
}

private static boolean isContractDescriptor(File file) {
// TODO: Consider script injections implications...
return file.isFile() && file.getName().endsWith(".groovy");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public StubRunner(StubRunnerOptions stubRunnerOptions, String repositoryPath,
MessageVerifier<?> contractVerifierMessaging) {
this.stubsConfiguration = stubsConfiguration;
this.stubRunnerOptions = stubRunnerOptions;
this.stubRepository = new StubRepository(new File(repositoryPath));
this.stubRepository = new StubRepository(new File(repositoryPath), this.stubRunnerOptions);
AvailablePortScanner portScanner = new AvailablePortScanner(
stubRunnerOptions.getMinPortValue(), stubRunnerOptions.getMaxPortValue());
this.localStubRunner = new StubRunnerExecutor(portScanner, contractVerifierMessaging);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,22 @@ public class StubRunnerOptions {
*/
private final StubRunnerProxyOptions stubRunnerProxyOptions;

/**
* Should only stubs applicable for the given consumer get registered
*/
private boolean stubsPerConsumer = false;

/**
* Name of the consumer. If not set should default to {@code spring.application.name}
*/
private String consumerName;

StubRunnerOptions(Integer minPortValue, Integer maxPortValue,
String stubRepositoryRoot, boolean workOffline, String stubsClassifier,
Collection<StubConfiguration> dependencies,
Map<StubConfiguration, Integer> stubIdsToPortMapping,
String username, String password, final StubRunnerProxyOptions stubRunnerProxyOptions) {
String username, String password, final StubRunnerProxyOptions stubRunnerProxyOptions,
boolean stubsPerConsumer, String consumerName) {
this.minPortValue = minPortValue;
this.maxPortValue = maxPortValue;
this.stubRepositoryRoot = stubRepositoryRoot;
Expand All @@ -90,6 +101,8 @@ public class StubRunnerOptions {
this.username = username;
this.password = password;
this.stubRunnerProxyOptions = stubRunnerProxyOptions;
this.stubsPerConsumer = stubsPerConsumer;
this.consumerName = consumerName;
}

public Integer port(StubConfiguration stubConfiguration) {
Expand Down Expand Up @@ -121,6 +134,22 @@ public StubRunnerProxyOptions getProxyOptions() {
return this.stubRunnerProxyOptions;
}

public boolean isStubsPerConsumer() {
return this.stubsPerConsumer;
}

public void setStubsPerConsumer(boolean stubsPerConsumer) {
this.stubsPerConsumer = stubsPerConsumer;
}

public String getConsumerName() {
return this.consumerName;
}

public void setConsumerName(String consumerName) {
this.consumerName = consumerName;
}

public static class StubRunnerProxyOptions {

private final String proxyHost;
Expand Down Expand Up @@ -151,7 +180,9 @@ public int getProxyPort() {
+ ", workOffline=" + this.workOffline + ", stubsClassifier='" + this.stubsClassifier
+ '\'' + ", dependencies=" + this.dependencies + ", stubIdsToPortMapping="
+ this.stubIdsToPortMapping + ", username='" + this.username + '\'' + ", password='"
+ this.password + '\'' + ", stubRunnerProxyOptions=" + this.stubRunnerProxyOptions
+ this.password + '\'' + ", stubRunnerProxyOptions='" + this.stubRunnerProxyOptions + "', stubsPerConsumer='"
+ this.stubsPerConsumer
+ '\'' + ", stubsPerConsumer='" + this.stubsPerConsumer + '\''
+ '}';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public class StubRunnerOptionsBuilder {
private String username;
private String password;
private StubRunnerOptions.StubRunnerProxyOptions stubRunnerProxyOptions;
private boolean stubPerConsumer = false;
private String consumerName;

public StubRunnerOptionsBuilder() {
}
Expand Down Expand Up @@ -118,7 +120,7 @@ public StubRunnerOptionsBuilder withOptions(StubRunnerOptions options) {
public StubRunnerOptions build() {
return new StubRunnerOptions(this.minPortValue, this.maxPortValue, this.stubRepositoryRoot,
this.workOffline, this.stubsClassifier, buildDependencies(), this.stubIdsToPortMapping,
this.username, this.password, this.stubRunnerProxyOptions);
this.username, this.password, this.stubRunnerProxyOptions, this.stubPerConsumer, this.consumerName);
}

private Collection<StubConfiguration> buildDependencies() {
Expand Down Expand Up @@ -193,4 +195,14 @@ public StubRunnerOptionsBuilder withProxy(final String proxyHost, final int prox
this.stubRunnerProxyOptions = new StubRunnerOptions.StubRunnerProxyOptions(proxyHost, proxyPort);
return this;
}

public StubRunnerOptionsBuilder withStubPerConsumer(boolean stubPerConsumer) {
this.stubPerConsumer = stubPerConsumer;
return this;
}

public StubRunnerOptionsBuilder withConsumerName(String consumerName) {
this.consumerName = consumerName;
return this;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ private StubRunnerOptions defaultStubRunnerOptions() {
.withStubsClassifier(System.getProperty("stubrunner.classifier", "stubs"))
.withStubs(System.getProperty("stubrunner.ids", ""))
.withUsername(System.getProperty("stubrunner.username"))
.withPassword(System.getProperty("stubrunner.password"));
.withPassword(System.getProperty("stubrunner.password"))
.withStubPerConsumer(Boolean.parseBoolean(System.getProperty("stubrunner.stubsPerConsumer", "false")))
.withConsumerName(System.getProperty("stubrunner.consumer-name"));
String proxyHost = System.getProperty("stubrunner.proxy.host");
if (proxyHost != null) {
builder.withProxy(proxyHost, Integer.parseInt(System.getProperty("stubrunner.proxy.port")));
Expand Down Expand Up @@ -201,6 +203,22 @@ public StubRunnerRule withPort(Integer port) {
return this;
}

/**
* Allows stub per consumer
*/
public StubRunnerRule withStubPerConsumer(boolean stubPerConsumer) {
this.stubRunnerOptionsBuilder.withStubPerConsumer(stubPerConsumer);
return this;
}

/**
* Allows setting consumer name
*/
public StubRunnerRule withConsumerName(String consumerName) {
this.stubRunnerOptionsBuilder.withConsumerName(consumerName);
return this;
}

@Override
public URL findStubUrl(String groupId, String artifactId) {
return this.stubFinder.findStubUrl(groupId, artifactId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,33 @@
* The classifier to use by default in ivy co-ordinates for a stub.
*/
String classifier() default "stubs";

/**
* On the producer side the consumers can have a folder that contains contracts related only to them. By setting the flag to {@code true}
* we no longer register all stubs but only those that correspond to the consumer application's name. In other words
* we'll scan the path of every stub and if it contains the name of the consumer in the path only then will it get registered.
*
* Let's look at this example. Let's assume
* that we have a producer called {@code foo} and two consumers {@code baz} and {@code bar}. On the {@code foo} producer side the
* contracts would look like this
* {@code src/test/resources/contracts/baz-service/some/contracts/...} and
* {@code src/test/resources/contracts/bar-service/some/contracts/...}.
*
* Then when the consumer with {@code spring.application.name} or the {@link AutoConfigureStubRunner#consumerName()}
* annotation parameter set to {@code baz-service} will define the test setup as follows
* {@code @AutoConfigureStubRunner(ids = "com.example:foo:+:stubs:8095", stubsPerConsumer=true)} then only the stubs registered
* under {@code src/test/resources/contracts/baz-service/some/contracts/...} will get registered and those under
* {@code src/test/resources/contracts/bar-service/some/contracts/...} will get ignored.
*
* @see <a href="https://github.com/spring-cloud/spring-cloud-contract/issues/224">issue 224</a>
*
*/
boolean stubsPerConsumer() default false;

/**
* You can override the default {@code spring.application.name} of this field by setting a value to this parameter.
*
* @see <a href="https://github.com/spring-cloud/spring-cloud-contract/issues/224">issue 224</a>
*/
String consumerName() default "";
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import org.springframework.cloud.contract.stubrunner.StubDownloader;
import org.springframework.cloud.contract.stubrunner.StubRunnerOptions;
import org.springframework.cloud.contract.stubrunner.StubRunnerOptionsBuilder;
import org.springframework.cloud.contract.stubrunner.util.StringUtils;
import org.springframework.cloud.contract.verifier.messaging.MessageVerifier;
import org.springframework.cloud.contract.verifier.messaging.noop.NoOpStubMessages;
import org.springframework.context.annotation.Bean;
Expand Down Expand Up @@ -92,7 +93,16 @@ private StubRunnerOptionsBuilder builder() throws IOException {
.withStubsClassifier(this.props.getClassifier())
.withStubs(this.props.getIds())
.withUsername(this.props.getUsername())
.withPassword(this.props.getPassword());
.withPassword(this.props.getPassword())
.withStubPerConsumer(this.props.isStubsPerConsumer())
.withConsumerName(consumerName());
}

private String consumerName() {
if (StringUtils.hasText(this.props.getConsumerName())) {
return this.props.getConsumerName();
}
return this.environment.getProperty("spring.application.name");
}

private String uriStringOrEmpty(Resource stubRepositoryRoot) throws IOException {
Expand Down
Loading

0 comments on commit 808684f

Please sign in to comment.