Using Swagger Request Validator to Validate Spring Cloud Contracts

Using Swagger Request Validator to Validate Spring Cloud Contracts

1 Comment

The Swagger Request Validator is a slick project supported by the folks at Atlassian.

Using your API definition in Swagger v2 or OpenAPI v3, it allows you to programmatically validate your API matches your API specification.

The Swagger Request Validator may be used standalone, or with Spring MVC, Spring MockMVC, Spring Web Client, REST Assured, WireMock, or Pact.

Validations the Swagger Request Validator can perform include:

  • Valid API Path / Operation
  • Request Body – expected and if matches JSON Schema
  • Missing Header Parameters
  • Missing or Invalid query parameters
  • Valid Content Type Headers
  • Valid Accept Headers
  • Response Body – if expected and if it matches the JSON schema

In this post, I’ll show you how to define contracts in OpenAPI for Spring Cloud Contract, and how to configure the Atlassian Swagger Request Validator to validate API interactions performed by tests automatically generated by Spring Cloud Contract.

In this example, we will look at a simple Payor service, to look up Payor details for a given Payor Id.

API Request

OpenAPI Operation

The OpenAPI operation is defined as follows:

paths:
    /v1/payors/{payorId}:
        get:
            summary: Get Payor Details by ID
            description: |
                This API operation provide you the account balance of the given payor id.
            operationId: getPayor
            tags:
                - Payor Service
            parameters:
                -   name: payorId
                    in: path
                    description: The account owner Payor ID
                    required: true
                    schema:
                        type: string
                        format: uuid

This is a simple API request. An HTTP GET is performed against the path /v1/payors/{payorId}; where payorId is the UUID of the desired payor.

Spring Framework Rest Controller

The Spring Framework Rest Controller is implemented as follows:

@RestController
@RequestMapping("/v1/payors")
public class PayorControllor {

    private final PayorService payorService;

    public PayorControllor(PayorService payorService) {
        this.payorService = payorService;
    }

    @GetMapping(value = "/{payorId}")
    public ResponseEntity<Payor> getPayor(@PathVariable("payorId") UUID payorId){

        Optional<Payor> optionalPayor = payorService.getPayor(payorId);

        if (optionalPayor.isPresent()) {
            return ResponseEntity.of(optionalPayor);
        } else {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
        }
    }

}

API Response

Open API Response Schema

The response for the operation is defined in YAML as follows:

            responses:
                '200':
                    description: Get Payor Details
                    content:
                        application/json:
                            schema:
                                type: object
                                required:
                                    - payorId
                                    - payorName
                                    - address
                                    - primaryContactName
                                    - primaryContactPhone
                                    - primaryContactEmail
                                    - language
                                properties:
                                    payorId:
                                        type: string
                                        format: uuid
                                    payorName:
                                        type: string
                                    address:
                                        type: object
                                        required:
                                            - line1
                                            - city
                                            - zipOrPostcode
                                            - country
                                        properties:
                                            line1:
                                                type: string
                                                minLength: 2
                                                maxLength: 255
                                            line2:
                                                type: string
                                                minLength: 0
                                                maxLength: 255
                                            line3:
                                                type: string
                                                minLength: 0
                                                maxLength: 255
                                            line4:
                                                type: string
                                                minLength: 0
                                                maxLength: 255
                                            city:
                                                type: string
                                                minLength: 2
                                                maxLength: 100
                                            countyOrProvince:
                                                type: string
                                                minLength: 2
                                                maxLength: 100
                                            zipOrPostcode:
                                                type: string
                                                minLength: 2
                                                maxLength: 30
                                            country:
                                                type: string
                                                minLength: 2
                                                maxLength: 50
                                    primaryContactName:
                                        type: string
                                        description: Name of primary contact for the payor.
                                    primaryContactPhone:
                                        type: string
                                        description: Primary contact phone number for the payor.
                                    primaryContactEmail:
                                        type: string
                                        format: email
                                        description: Primary contact email for the payor.
                                    fundingAccountRoutingNumber:
                                        type: string
                                        description: The funding account routing number to be used for the payor.
                                    fundingAccountAccountNumber:
                                        type: string
                                        description: The funding account number to be used for the payor.
                                    fundingAccountAccountName:
                                        type: string
                                        description: The funding account name to be used for the payor.
                                    kycState:
                                        type: string
                                        description: The kyc state of the payor.
                                        enum: [FAILED_KYC, PASSED_KYC, REQUIRES_KYC]
                                    manualLockout:
                                        type: boolean
                                        description: Whether or not the payor has been manually locked by the backoffice.
                                    payeeGracePeriodProcessingEnabled:
                                        type: boolean
                                        description: Whether grace period processing is enabled.
                                    payeeGracePeriodDays:
                                        type: integer
                                        description: The grace period for paying payees in days.
                                    collectiveAlias:
                                        type: string
                                        description: How the payor has chosen to refer to payees.
                                    supportContact:
                                        type: string
                                        description: The payor’s support contact address.
                                    dbaName:
                                        type: string
                                        description: The payor’s 'Doing Business As' name.
                                    allowsLanguageChoice:
                                        type: boolean
                                        description: Whether or not the payor allows language choice in the UI.
                                    reminderEmailsOptOut:
                                        type: boolean
                                        description: Whether or not the payor has opted-out of reminder emails being sent.
                                    language:
                                        type: string
                                        description: The payor’s language preference. Must be one of [EN, FR].
                                        enum: [EN, FR]
                                    includesReports:
                                        type: boolean

Java Response Objects

The following Java Objects are defined to support the response:

Payor.java

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Payor {
    /**
     * The payor id (UUID).
     */
    @NotNull(message="The Payor Id is mandatory")
    private String payorId;

    /**
     * The name of the payor.
     */
    @NotNull(message="The Payor Name is mandatory")
    private String payorName;

    /**
     * The address of the payor.
     */
    @NotNull(message="The Address is mandatory")
    private Address address;

    /**
     * Name of primary contact for the payor.
     */
    @NotNull(message="The Primary Contact Name is mandatory")
    private String primaryContactName;

    /**
     * Primary contact phone number for the payor.
     */
    @NotNull(message="The Primary Contact Phone is mandatory")
    private String primaryContactPhone;

    /**
     * Primary contact email for the payor.
     */
    @NotNull(message="The Primary Contact Email is mandatory")
    private String primaryContactEmail;

    /**
     * The funding account routing number to be used for the payor.
     */
    private String fundingAccountRoutingNumber;

    /**
     * The funding account number to be used for the payor.
     */
    private String fundingAccountAccountNumber;

    /**
     * The funding account name to be used for the payor.
     */
    private String fundingAccountAccountName;

    /**
     * The kyc state of the payor.
     */
    private KycState kycState;

    /**
     * Whether or not the payor has been manually locked by the backoffice.
     */
    private Boolean manualLockout;

    /**
     * Whether grace period processing is enabled.
     */
    private Boolean payeeGracePeriodProcessingEnabled;

    /**
     * The grace period for paying payees in days.
     */
    private Integer payeeGracePeriodDays;

    /**
     * How the payor has chosen to refer to payees
     */
    private String collectiveAlias;

    /**
     * The payor's support contact address
     */
    private String supportContact;

    /**
     * The payor's 'Doing Business As' name
     */
    private String dbaName;

    /**
     * Whether or not the payor allows language choice in the UI
     */
    private Boolean allowsLanguageChoice;

    /**
     * Whether or not the payor has opted-out of reminder emails being sent
     */
    private Boolean reminderEmailsOptOut;

    /**
     * The payor's language preference
     */
    @NotNull(message = "The Preferred Language is mandatory")
    private Language language = Language.EN;

    // flag to indicate whether a payor should see reports
    private Boolean includesReports;
}

Address.java

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Address {
    @Size(min=2, max=255, message = "Line 1 of the address must be between {min} and {max} characters long")
    @NotNull(message = "Line 1 of the address is mandatory")
    private String line1;

    @Size(max = 255, message = "Line 2 of the address can be no more than {max} characters long")
    private String line2;

    @Size(max = 255, message = "Line 3 of the address can be no more than {max} characters long")
    private String line3;

    @Size(max = 255, message = "Line 4 of the address can be no more than {max} characters long")
    private String line4;

    @Size(min=2, max=100, message = "City must be between {min} and {max} characters long")
    @NotNull(message = "City is mandatory")
    private String city;

    @Size(min=2, max=100, message = "County or Province must be between {min} and {max} characters long")
    private String countyOrProvince;

    @Size(min=2, max=30, message = "Zip or Postcode must be between {min} and {max} characters long")
    private String zipOrPostcode;

    @Size(min=2, max=50, message = "Country must be between {min} and {max} characters long")
    @NotNull(message = "Country is mandatory")
    private String country;
}

Language.java

public enum  Language {
    EN, FR
}

KycState.java

public enum KycState {
    FAILED_KYC, PASSED_KYC, REQUIRES_KYC;
}

Spring Cloud Contracts Defined in OA3

In this example, we will define 3 contracts. This example uses my Spring Cloud Contact OA3 converter explained here.

paths:
    /v1/payors/{payorId}:
        get:
        ....
            x-contracts:
                - contractId: 1
                  name: Test Good Response
                  contractPath: "/v1/payors/0a818933-087d-47f2-ad83-2f986ed087eb"
                - contractId: 2
                  name: Test Not Found
                  contractPath: "/v1/payors/00000000-0000-0000-0000-000000000000"
                - contractId: 3
                  name: Test Bad Schema
                  contractPath: "/v1/payors/16ea5c75-1476-4651-b117-bcb33d77f2b5"
        ....
            responses:
                '200':
                    x-contracts:
                        - contractId: 1
                          body:
                              payorId: "0a818933-087d-47f2-ad83-2f986ed087eb"
                '404':
                    description: Payor Id Not Found
                    x-contracts:
                        - contractId: 2
                '500':
                    description: Server Error
                    x-contracts:
                        -   contractId: 3
                            body:
                                payorId: "0a818933-087d-47f2-ad83-2f986ed087eb"

Maven POM

In your Maven POM, you will need to add dependencies for Spring Cloud Contract, the SFG Spring Cloud Contract OA3 Converter, Spring (in general), Atlassian.

Note the configuration of the Spring Cloud Contract Maven plugin.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.1.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>guru.sfgpay</groupId>
    <artifactId>sfg-payor-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>
    <name>sfg-payor-service</name>
    <description>Payor Service</description>

    <properties>
        <java.version>1.8</java.version>
        <spring-cloud.version>Greenwich.M3</spring-cloud.version>
        <swagger-request-validator.version>2.1.0</swagger-request-validator.version>
        <spring-cloud-dependencies.version>Finchley.BUILD-SNAPSHOT</spring-cloud-dependencies.version>
        <spring-cloud-contract.version>2.0.2.RELEASE</spring-cloud-contract.version>
        <scc-oa3-version>2.0.2.2</scc-oa3-version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-contract-verifier</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>guru.springframework</groupId>
            <artifactId>spring-cloud-contract-oa3</artifactId>
            <version>${scc-oa3-version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.atlassian.oai</groupId>
            <artifactId>swagger-request-validator-core</artifactId>
            <version>${swagger-request-validator.version}</version>
            <!--<scope>test</scope>-->
        </dependency>
        <dependency>
            <groupId>com.atlassian.oai</groupId>
            <artifactId>swagger-request-validator-mockmvc</artifactId>
            <version>${swagger-request-validator.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.atlassian.oai</groupId>
            <artifactId>swagger-request-validator-springmvc</artifactId>
            <version>${swagger-request-validator.version}</version>
        </dependency>
        <dependency>
            <groupId>com.atlassian.oai</groupId>
            <artifactId>swagger-request-validator-restassured</artifactId>
            <version>${swagger-request-validator.version}</version>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-contract-maven-plugin</artifactId>
                <version>${spring-cloud-contract.version}</version>
                <extensions>true</extensions>
                <configuration>
                    <testMode>EXPLICIT</testMode>
                    <packageWithBaseClasses>guru.sfgpay</packageWithBaseClasses>
                    <contractsPath>classpath:/oa3</contractsPath>
                    <contractsDirectory>${pom.basedir}/src/main/resources/oa3</contractsDirectory>
                </configuration>
                <dependencies>
                    <!--needed to include oa3 converter-->
                    <dependency>
                        <groupId>guru.springframework</groupId>
                        <artifactId>spring-cloud-contract-oa3</artifactId>
                        <version>${scc-oa3-version}</version>
                    </dependency>
                </dependencies>
            </plugin>
        </plugins>
    </build>
    <repositories>
        <repository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/milestone</url>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
    </repositories>
</project>

Enabling OA3 Validation With Swagger Request Validator

There are several options to enable OA3 validation with the Swagger Request Validator.

A challenge to deal with is how Spring Cloud Contract (SCC) generates its unit tests.

SCC by default uses Rest Assured, which is supported by the Swagger Request Validator.

But working from the SCC DSL, I was unable to figure out how to enable the OA3 validation.

The easiest approach I was able to find was avoid the Mock Servlet requests and run integration tests using embedded Tomcat (via Spring Boot).

With this integration, its easy to configure an interceptor for the OA3 validation.

OpenAPI Validation Interceptor

Here is the configuration for the OpenAPI Validation Interceptor

@Configuration
@Profile("oa3-validate")
public class OpenApiValidationConfig implements WebMvcConfigurer {

    private final OpenApiValidationInterceptor validationInterceptor;

    @Autowired
    public OpenApiValidationConfig() throws IOException {

        this.validationInterceptor = new OpenApiValidationInterceptor(OpenApiInteractionValidator
                .createFor("/oa3/openapi.yml").build());
    }

    @Bean
    public Filter validationFilter() {
        return new OpenApiValidationFilter(
                true, // enable request validation
                true  // enable response validation
        );
    }

    @Override
    public void addInterceptors(final InterceptorRegistry registry) {
        registry.addInterceptor(validationInterceptor);
    }
}

Spring Cloud Contract Configuration

Spring Cloud Contract is configured to use Explicit mode – ie web server on localhost.

Spring Cloud Contract Base Test Class

@RunWith(SpringRunner.class)
@SpringBootTest(classes = SfgPayorServiceApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("oa3-validate")
public class ContractVerifierBase {

    //by classpath
    private static final String OA3_URL = "/oa3/openapi.yml";
    private final OpenApiValidationFilter validationFilter = new OpenApiValidationFilter(OA3_URL);

    @LocalServerPort
    int port;

    @Before
    public void setUp() throws Exception {
        RestAssured.baseURI = "http://localhost";
        RestAssured.port = this.port;
    }
}

Spring Cloud Contract Maven Configuration

Note the following configuration properties for SCC. Very important to set testMode to EXPLICIT.

<build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-contract-maven-plugin</artifactId>
                <version>${spring-cloud-contract.version}</version>
                <extensions>true</extensions>
                <configuration>
                    <testMode>EXPLICIT</testMode>
                    <packageWithBaseClasses>guru.sfgpay</packageWithBaseClasses>
                    <contractsPath>classpath:/oa3</contractsPath>
                    <contractsDirectory>${pom.basedir}/src/main/resources/oa3</contractsDirectory>
                </configuration>
                <dependencies>
                    <!--needed to include oa3 converter-->
                    <dependency>
                        <groupId>guru.springframework</groupId>
                        <artifactId>spring-cloud-contract-oa3</artifactId>
                        <version>${scc-oa3-version}</version>
                    </dependency>
                </dependencies>
            </plugin>
        </plugins>
    </build>

Test Generated by Spring Cloud Contract

From the configuration above, the following tests are generated by Spring Cloud Contact.

public class ContractVerifierTest extends ContractVerifierBase {
    public ContractVerifierTest() {
    }

    @Test
    public void validate_test_Good_Response() throws Exception {
        RequestSpecification request = RestAssured.given();
        Response response = (Response)RestAssured.given().spec(request).get("/v1/payors/0a818933-087d-47f2-ad83-2f986ed087eb", new Object[0]);
        SpringCloudContractAssertions.assertThat(response.statusCode()).isEqualTo(200);
        DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
        JsonAssertion.assertThatJson(parsedJson).field("['payorId']").isEqualTo("0a818933-087d-47f2-ad83-2f986ed087eb");
    }

    @Test
    public void validate_test_Not_Found() throws Exception {
        RequestSpecification request = RestAssured.given();
        Response response = (Response)RestAssured.given().spec(request).get("/v1/payors/00000000-0000-0000-0000-000000000000", new Object[0]);
        SpringCloudContractAssertions.assertThat(response.statusCode()).isEqualTo(404);
    }

    @Test
    public void validate_test_Bad_Schema() throws Exception {
        RequestSpecification request = RestAssured.given();
        Response response = (Response)RestAssured.given().spec(request).get("/v1/payors/16ea5c75-1476-4651-b117-bcb33d77f2b5", new Object[0]);
        SpringCloudContractAssertions.assertThat(response.statusCode()).isEqualTo(500);
        DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
        JsonAssertion.assertThatJson(parsedJson).field("['payorId']").isEqualTo("0a818933-087d-47f2-ad83-2f986ed087eb");
    }
}

Test Output

For the 3rd test, I configured the service to return back a Payor object missing required properties.

Here is the console output of the test where you can see the validation failure.

 2018-12-13 15:36:17.653 ERROR 27406 --- [o-auto-1-exec-2] c.a.o.v.s.DefaultValidationReportHandler : OpenAPI location=RESPONSE key=GET#/v1/payors/16ea5c75-1476-4651-b117-bcb33d77f2b5 levels=ERROR messages=Validation failed.
[ERROR][RESPONSE][GET /v1/payors/16ea5c75-1476-4651-b117-bcb33d77f2b5] Object has missing required properties (["language","payorId","payorName","primaryContactEmail","primaryContactName","primaryContactPhone"])
[ERROR][RESPONSE][GET /v1/payors/16ea5c75-1476-4651-b117-bcb33d77f2b5] [Path '/address'] Object has missing required properties (["city","country","line1","zipOrPostcode"])

Source Code

The complete source code is available here in my GitHub repository.

Conclusion

With the above configuration, your OpenAPI specification will be validated with every request / response made my Spring Cloud Contract.

 

 

About jt

    You May Also Like

    One comment

    1. December 17, 2018 at 4:17 pm

      Thanks a lot for your help.

      Reply

    Leave a Reply

    Your email address will not be published. Required fields are marked *