Search code examples
restprotocol-buffersspring-cloud-contract

RESTful service contracts for protobuf definitions


Design Overview

  • Request and response objects are modelled in protobuf.
  • Classes are generated in Python and Java using protoc.
  • Users create request objects in Python and send it to RESTful Java Spring Boot microservices.
  • A JavaScript React web application and Node server also invoke the RESTful endpoints.
  • Requests and responses are serialized to Json.
  • 1+ Java microservices may use the same request/response objects. eg. an aggregator/API gateway microservice will pass on the request to the actual microservice, providing the relevant service.

Question

Protobuf enforces some level of type checking, and a kind of contract, for request/response objects. However, how do I develop, maintain and enforce RESTful contracts (HTTP verb + path + request + response)?

Is this the way to go?

Develop contracts in Spring Cloud Contract and auto-generate integration contract tests.


Solution

  • You can check out a sample that uses protobuffers with spring cloud contract on the producer side here https://github.com/spring-cloud-samples/spring-cloud-contract-samples/tree/master/producer_proto and on the consumer side here https://github.com/spring-cloud-samples/spring-cloud-contract-samples/tree/master/consumer_proto

    The very idea is to treat the content as binary. Let's say that I stored the request and response in a binary format in a .bin file. Then I can create the following contract

    package contracts.beer.rest
    
    
    import org.springframework.cloud.contract.spec.Contract
    
    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 '/check'
            body(fileAsBytes("PersonToCheck_old_enough.bin"))
            headers {
                contentType("application/x-protobuf")
            }
        }
        response {
            status 200
            body(fileAsBytes("Response_old_enough.bin"))
            headers {
                contentType("application/x-protobuf")
            }
        }
    }
    

    Having such a controller

    @RestController
    public class ProducerController {
    
        private final PersonCheckingService personCheckingService;
    
        public ProducerController(PersonCheckingService personCheckingService) {
            this.personCheckingService = personCheckingService;
        }
    
        @RequestMapping(value = "/check",
                method=RequestMethod.POST,
                consumes="application/x-protobuf",
                produces="application/x-protobuf")
        public Beer.Response check(@RequestBody Beer.PersonToCheck personToCheck) {
            //remove::start[]
            if (this.personCheckingService.shouldGetBeer(personToCheck)) {
                return Beer.Response.newBuilder().setStatus(Beer.Response.BeerCheckStatus.OK).build();
            }
            return Beer.Response.newBuilder().setStatus(Beer.Response.BeerCheckStatus.NOT_OK).build();
            //remove::end[return]
        }
        
    }
    
    interface PersonCheckingService {
        Boolean shouldGetBeer(Beer.PersonToCheck personToCheck);
    }
    

    and such a base class for the generated tests (I assume that you've setup the contract plugin)

    package com.example;
    
    //remove::start[]
    import io.restassured.module.mockmvc.RestAssuredMockMvc;
    //remove::end[]
    import org.junit.Before;
    import org.junit.runner.RunWith;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.context.annotation.Import;
    import org.springframework.test.context.junit4.SpringRunner;
    import org.springframework.web.context.WebApplicationContext;
    
    @RunWith(SpringRunner.class)
    @SpringBootTest(classes = BeerRestBase.Config.class)
    public abstract class BeerRestBase {
    
        @Autowired
        WebApplicationContext context;
    
        //remove::start[]
        @Before
        public void setup() {
            RestAssuredMockMvc.webAppContextSetup(this.context);
        }
        // remove::end[]
    
        @Configuration
        @EnableAutoConfiguration
        @Import({ ProtoConfiguration.class, ProducerController.class })
        static class Config {
    
            @Bean
            PersonCheckingService personCheckingService() {
                return argument -> argument.getAge() >= 20;
            }
    
        }
    
    }
    

    will result in proper test and stub generation. Check out the aforementioned sample for concrete implementation details.

    On the consumer side you can fetch the stubs and run your tests against them

    package com.example;
    
    import org.assertj.core.api.BDDAssertions;
    import org.junit.Assume;
    import org.junit.Before;
    import org.junit.BeforeClass;
    import org.junit.Rule;
    import org.junit.Test;
    import org.junit.runner.RunWith;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
    import org.springframework.cloud.contract.stubrunner.junit.StubRunnerRule;
    import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties;
    import org.springframework.test.context.junit4.SpringRunner;
    import org.springframework.util.StringUtils;
    import org.springframework.web.client.RestTemplate;
    
    /**
     * @author Marcin Grzejszczak
     */
    @RunWith(SpringRunner.class)
    @SpringBootTest(webEnvironment = WebEnvironment.NONE)
    public class ProtoTest {
    
        @Autowired
        RestTemplate restTemplate;
    
        int port;
    
        @Rule
        public StubRunnerRule rule = new StubRunnerRule()
                .downloadStub("com.example", "beer-api-producer-proto")
                .stubsMode(StubRunnerProperties.StubsMode.LOCAL);
    
        @Before
        public void setupPort() {
            this.port = this.rule.findStubUrl("beer-api-producer-proto").getPort();
        }
    
        @Test
        public void should_give_me_a_beer_when_im_old_enough() throws Exception {
            Beer.Response response = this.restTemplate.postForObject(
                    "http://localhost:" + this.port + "/check",
                    Beer.PersonToCheck.newBuilder().setAge(23).build(), Beer.Response.class);
    
            BDDAssertions.then(response.getStatus()).isEqualTo(Beer.Response.BeerCheckStatus.OK);
        }
    
        @Test
        public void should_reject_a_beer_when_im_too_young() throws Exception {
            Beer.Response response = this.restTemplate.postForObject(
                    "http://localhost:" + this.port + "/check",
                    Beer.PersonToCheck.newBuilder().setAge(17).build(), Beer.Response.class);
            response = response == null ? Beer.Response.newBuilder().build() : response;
    
            BDDAssertions.then(response.getStatus()).isEqualTo(Beer.Response.BeerCheckStatus.NOT_OK);
        }
    }
    

    Again, please check the concrete sample for implementation details.