From 33c72193364f74c241b728cc92d3406f53192f35 Mon Sep 17 00:00:00 2001 From: Marcin Grzejszczak Date: Thu, 17 Sep 2020 16:27:57 +0200 Subject: [PATCH] Custom mode & GRPC support --- .../asciidoc/_project-features-flows.adoc | 382 +++++++++++++++++- 1 file changed, 381 insertions(+), 1 deletion(-) diff --git a/docs/src/main/asciidoc/_project-features-flows.adoc b/docs/src/main/asciidoc/_project-features-flows.adoc index 390c71e04a..b803ae1948 100644 --- a/docs/src/main/asciidoc/_project-features-flows.adoc +++ b/docs/src/main/asciidoc/_project-features-flows.adoc @@ -127,6 +127,108 @@ include::{samples_url}/producer_webflux/src/test/java/com/example/BeerRestBase.j ---- ==== +[[features-custom-mode]] +=== Custom Mode + +IMPORTANT: This mode is experimental and can change in the future! + +The Spring Cloud Contract allows you to provide your own, custom implementation of the +`org.springframework.cloud.contract.verifier.http.HttpVerifier`. That way you can use any client you want to send and receive a request. The default implementation in Spring Cloud Contract is `OkHttpHttpVerifier` and it leverages OkHttp3 http client. + +==== +[source,groovy,indent=0] +---- +testMode = 'CUSTOM' +---- +==== + +The following example shows a generated test: + +==== +[source,java,indent=0] +---- +package com.example.beer; + +import com.example.BeerRestBase; +import javax.inject.Inject; +import org.springframework.cloud.contract.verifier.http.HttpVerifier; +import org.springframework.cloud.contract.verifier.http.Request; +import org.springframework.cloud.contract.verifier.http.Response; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.springframework.cloud.contract.verifier.assertion.SpringCloudContractAssertions.assertThat; +import static org.springframework.cloud.contract.verifier.util.ContractVerifierUtil.*; +import static org.springframework.cloud.contract.verifier.http.Request.given; + +@SuppressWarnings("rawtypes") +public class RestTest extends BeerRestBase { + @Inject HttpVerifier httpVerifier; + + @Test + public void validate_shouldGrantABeerIfOldEnough() throws Exception { + // given: + Request request = given() + .post("/beer.BeerService/check") + .scheme("HTTP") + .protocol("h2_prior_knowledge") + .header("Content-Type", "application/grpc") + .header("te", "trailers") + .body(fileToBytes(this, "shouldGrantABeerIfOldEnough_request_PersonToCheck_old_enough.bin")) + .build(); + + + // when: + Response response = httpVerifier.exchange(request); + + + // then: + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.header("Content-Type")).matches("application/grpc.*"); + assertThat(response.header("grpc-encoding")).isEqualTo("identity"); + assertThat(response.header("grpc-accept-encoding")).isEqualTo("gzip"); + + // and: + assertThat(response.getBody().asByteArray()).isEqualTo(fileToBytes(this, "shouldGrantABeerIfOldEnough_response_Response_old_enough.bin")); + } + +} +---- +==== + +Corresponding base class could look like this: + +==== +[source,java,indent=0] +---- +@SpringBootTest(classes = BeerRestBase.Config.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class BeerRestBase { + + @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(@LocalServerPort int port) { + return new OkHttpHttpVerifier("localhost:" + port); + } + + } +} +---- +==== + [[features-context-paths]] === Working with Context Paths @@ -564,7 +666,7 @@ contracts { ---- ==== -The base class would set up the applicatoin running on a random port. +The base class would set up the application running on a random port. ==== [source,java,indent=0,subs="verbatim,attributes"] @@ -622,5 +724,283 @@ public class BeerControllerGraphQLTest { } } +---- +==== + +[[features-grpc]] +=== GRPC + +https://grpc.io/[GRPC] is an RPC framework built on top of HTTP/2 for which Spring Cloud Contract has basic support. + +IMPORTANT: 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. + +==== +[source,groovy,indent=0,subs="verbatim,attributes"] +.Groovy contract +---- +package contracts.beer.rest + + +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() + ] + ]) +} +---- +==== + +[[features-grpc-producer]] +==== Producer Side Setup + +In order to leverage the HTTP/2 support you must set the `CUSTOM` test mode as follow. + +==== +[source,xml,indent=0,subs="verbatim,attributes",role="primary"] +.Maven +---- + + org.springframework.cloud + spring-cloud-contract-maven-plugin + ${spring-cloud-contract.version} + true + + CUSTOM + com.example + + +---- + +[source,groovy,indent=0,subs="verbatim,attributes",role="secondary"] +.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. + +==== +[source,java,indent=0,subs="verbatim,attributes"] +.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()); + } + + } +} +---- +==== + +[[features-grpc-consumer]] +==== 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. + +==== +[source,java,indent=0,subs="verbatim,attributes"] +.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 ClientCall interceptCall(MethodDescriptor method, CallOptions callOptions, Channel next) { + ClientCall call = next.newCall(method, callOptions); + return new ClientCall() { + @Override + public void start(Listener responseListener, Metadata headers) { + Listener listener = new Listener() { + @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); + } + }; + } + }; + } + } +} + ---- ==== \ No newline at end of file