Using Swagger Request Validator to Validate Spring Cloud Contracts
3 CommentsLast Updated on June 10, 2019 by Simanta
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.
Ahmed
Thanks a lot for your help.
Pandurang
I don’t find test class ContractVerifierTest in the git repo https://github.com/springframeworkguru/sfg-payor-service that you have shared.
Gaétan
for:
primaryContactEmail:
type: string
format: email
description: Primary contact email for the payor.
Why does this “format: email” does not generate an @Email annotation on java side?
If it’s normal, then the format is useless here, right?