For the latest stable version, please use spring-cloud-contract 4.2.0! |
GRPC
GRPC is an RPC framework built on top of HTTP/2 for which Spring Cloud Contract has basic support.
Spring Cloud Contract has an experimental support for basic use cases of GRPC. Unfortunately, due to GRPC’s tweaking of the HTTP/2 Header frames, it’s impossible to assert the grpc-status header.
|
Let’s look at the following contract.
Groovy contract
import org.springframework.cloud.contract.spec.Contract
import org.springframework.cloud.contract.verifier.http.ContractVerifierHttpMetaData
Contract.make {
description("""
Represents a successful scenario of getting a beer
```
given:
client is old enough
when:
he applies for a beer
then:
we'll grant him the beer
```
""")
request {
method 'POST'
url '/beer.BeerService/check'
body(fileAsBytes("PersonToCheck_old_enough.bin"))
headers {
contentType("application/grpc")
header("te", "trailers")
}
}
response {
status 200
body(fileAsBytes("Response_old_enough.bin"))
headers {
contentType("application/grpc")
header("grpc-encoding", "identity")
header("grpc-accept-encoding", "gzip")
}
}
metadata([
"verifierHttp": [
"protocol": ContractVerifierHttpMetaData.Protocol.H2_PRIOR_KNOWLEDGE.toString()
]
])
}
Producer Side Setup
In order to leverage the HTTP/2 support you must set the CUSTOM
test mode as follow.
Maven
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>${spring-cloud-contract.version}</version>
<extensions>true</extensions>
<configuration>
<testMode>CUSTOM</testMode>
<packageWithBaseClasses>com.example</packageWithBaseClasses>
</configuration>
</plugin>
Gradle
contracts {
packageWithBaseClasses = 'com.example'
testMode = "CUSTOM"
}
The base class would set up the application running on a random port. It will also set the HttpVerifier
implementation to one that can use the HTTP/2 protocol. Spring Cloud Contract comes with the OkHttpHttpVerifier
implementation.
Base Class
@SpringBootTest(classes = BeerRestBase.Config.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = {
"grpc.server.port=0"
})
public abstract class BeerRestBase {
@Autowired
GrpcServerProperties properties;
@Configuration
@EnableAutoConfiguration
static class Config {
@Bean
ProducerController producerController(PersonCheckingService personCheckingService) {
return new ProducerController(personCheckingService);
}
@Bean
PersonCheckingService testPersonCheckingService() {
return argument -> argument.getAge() >= 20;
}
@Bean
HttpVerifier httpOkVerifier(GrpcServerProperties properties) {
return new OkHttpHttpVerifier("localhost:" + properties.getPort());
}
}
}
Consumer Side Setup
Example of GRPC consumer side test. Due to the unusual behaviour of the GRPC server side, the stub is unable to return the grpc-status
header in the proper moment. This is why we need to manually set the return status.
Consumer Side Test
@SpringBootTest(webEnvironment = WebEnvironment.NONE, classes = GrpcTests.TestConfiguration.class, properties = {
"grpc.client.beerService.address=static://localhost:5432", "grpc.client.beerService.negotiationType=TLS"
})
public class GrpcTests {
@GrpcClient(value = "beerService", interceptorNames = "fixedStatusSendingClientInterceptor")
BeerServiceGrpc.BeerServiceBlockingStub beerServiceBlockingStub;
int port;
@RegisterExtension
static StubRunnerExtension rule = new StubRunnerExtension()
.downloadStub("com.example", "beer-api-producer-grpc")
// With WireMock PlainText mode you can just set an HTTP port
// .withPort(5432)
.stubsMode(StubRunnerProperties.StubsMode.LOCAL)
.withHttpServerStubConfigurer(MyWireMockConfigurer.class);
@BeforeEach
public void setupPort() {
this.port = rule.findStubUrl("beer-api-producer-grpc").getPort();
}
@Test
public void should_give_me_a_beer_when_im_old_enough() throws Exception {
Response response = beerServiceBlockingStub.check(PersonToCheck.newBuilder().setAge(23).build());
BDDAssertions.then(response.getStatus()).isEqualTo(Response.BeerCheckStatus.OK);
}
@Test
public void should_reject_a_beer_when_im_too_young() throws Exception {
Response response = beerServiceBlockingStub.check(PersonToCheck.newBuilder().setAge(17).build());
response = response == null ? Response.newBuilder().build() : response;
BDDAssertions.then(response.getStatus()).isEqualTo(Response.BeerCheckStatus.NOT_OK);
}
// Not necessary with WireMock PlainText mode
static class MyWireMockConfigurer extends WireMockHttpServerStubConfigurer {
@Override
public WireMockConfiguration configure(WireMockConfiguration httpStubConfiguration, HttpServerStubConfiguration httpServerStubConfiguration) {
return httpStubConfiguration
.httpsPort(5432);
}
}
@Configuration
@ImportAutoConfiguration(GrpcClientAutoConfiguration.class)
static class TestConfiguration {
// Not necessary with WireMock PlainText mode
@Bean
public GrpcChannelConfigurer keepAliveClientConfigurer() {
return (channelBuilder, name) -> {
if (channelBuilder instanceof NettyChannelBuilder) {
try {
((NettyChannelBuilder) channelBuilder)
.sslContext(GrpcSslContexts.forClient()
.trustManager(InsecureTrustManagerFactory.INSTANCE)
.build());
}
catch (SSLException e) {
throw new IllegalStateException(e);
}
}
};
}
/**
* GRPC client interceptor that sets the returned status always to OK.
* You might want to change the return status depending on the received stub payload.
*
* Hopefully in the future this will be unnecessary and will be removed.
*/
@Bean
ClientInterceptor fixedStatusSendingClientInterceptor() {
return new ClientInterceptor() {
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
ClientCall<ReqT, RespT> call = next.newCall(method, callOptions);
return new ClientCall<ReqT, RespT>() {
@Override
public void start(Listener<RespT> responseListener, Metadata headers) {
Listener<RespT> listener = new Listener<RespT>() {
@Override
public void onHeaders(Metadata headers) {
responseListener.onHeaders(headers);
}
@Override
public void onMessage(RespT message) {
responseListener.onMessage(message);
}
@Override
public void onClose(Status status, Metadata trailers) {
// TODO: This must be fixed somehow either in Jetty (WireMock) or somewhere else
responseListener.onClose(Status.OK, trailers);
}
@Override
public void onReady() {
responseListener.onReady();
}
};
call.start(listener, headers);
}
@Override
public void request(int numMessages) {
call.request(numMessages);
}
@Override
public void cancel(@Nullable String message, @Nullable Throwable cause) {
call.cancel(message, cause);
}
@Override
public void halfClose() {
call.halfClose();
}
@Override
public void sendMessage(ReqT message) {
call.sendMessage(message);
}
};
}
};
}
}
}