Spring Boot ft. JsonSchema - Payload validation
When big payloads are coming in, they should be validated and should be validated right. (Example made using Spring Boot + Gradle)
Brief
Let's think of a service that calculates taxes based on a massive input (person details, assets details, etc.).
public float calculate(MassivePayload massivePayload) {
if (massivePayload.getPerson()!=null) {
if (massivePayload.getPerson.getHouse()!=null) {
if(massivePayload.getPerson.getHouse().getType()!=null) {
return getTaxForHouse(massivePayload.getPerson.getHouse().getType());
}
}
}
}
private float getTaxForHouse(HouseType houseType) {
...
}
The code snippet above should theoretically work but it's a nightmare to read and to add or change constraints.
We should separate the concerns:
- Validate payload
- Run business logic
Payload validation is made easy by javax.validation.constraints
. It has a lot of annotation based constraints that when ran through a validator, they get triggered and return a set of ConstraintViolations
in case of an invalid input. So, we can have some conditions added in this way:
public class MassivePayload {
@NotNull(message = "Person cannot be null")
private Person person;
...
public Person getPerson(){
return person;
}
}
And the code in the method can be changed like this:
public float calculateTax(MassivePayload massivePayload) {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintViolation<MassivePayload>> violations = validator.validate(massivePayload);
if(violations.isEmpty()){
...
}
}
In case of a Controller method, validation can be done even more straightforward adding the @Valid
annotation next to the payload body.
@RestController("/tax")
public class TaxController{
@Post("/calculate")
public float calculateTax(@Valid @Body MassiveInput massiveInput) {
...
}
}
This is a better approach but still, on a big object with lots of fields and conditions, constraint management can still get cumbersome, enter JsonSchema
.
Finding
JsonSchema is a grammar language for defining the structure, content, and (to some extent) semantics of JSON objects. It lets you specify metadata (data about data) about what an object's properties mean and what values are valid for those properties.
Example:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Massive Input with details about a person ",
"type": "object",
"required": ["person"],
"properties": {
"person": {
"type": "object",
"required": ["lastName"],
"properties": {
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
}
}
}
}
}
This example is written using json schema draft-07
version. You can read more about different versions and features here: https://json-schema.org/specification.html
Library
Multiple Java implementations can be found for JsonSchema
validation: https://json-schema.org/implementations.html#from-code
In this example, I used com.networknt.json-schema-validator:1.0.59
.
The library needs a JsonSchema
file to validate incoming Json payloads against. In this example it is placed inside the jar's resource files, but it can be brought in from a remote location too. More details regarding this library can be found here: https://github.com/networknt/json-schema-validator
Configuration
@Configuration
public class JsonSchemaConfiguration {
private static final String SCHEMA_LOCATION = "/jsonSchema.json";
private final ObjectMapper mapper = new ObjectMapper();
@Bean
public JsonSchema getJsonSchema() throws IOException {
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
InputStream inputStream = getClass().getResourceAsStream(SCHEMA_LOCATION);
JsonNode schemaJson = objectMapper.readTree(inputStream);
JsonSchema jsonSchema = factory.getSchema(schemaJson);
jsonSchema.initializeValidators();
return jsonSchema;
}
}
Usage
The library accepts a JsonNode
object as an input and will return a set of ConstraintViolationError
when the incoming payload fails to meet one or more conditions specified in the JsonSchema
file.
After validation, the result can be mapped to a custom object and returned or thrown as an exception.
@Service
@RequiredArgsConstructor
public class JsonSchemaValidator {
private final JsonSchema jsonSchema;
public ValidationResult validate(JsonNode jsonNode) {
Set<ConstraintViolationError> validationErrors = jsonSchema.validate(jsonNode);
return ValidationResult.builder()
.valid(validationErrors.isEmpty())
.errors(validationErrors.stream()
.map(result -> ValidationError.builder()
.field(result.getPath())
.message(result.getMessage())
.build())
.collect(Collectors.toSet()))
.build();
}
}
In the end, this JsonSchemaValidator
service can be injected in a Filter
before it actually reaches the Controller
class and make sure the payload is fine when it's time to run business logic.
Bonus
When working with complex json schemas, the clean way to manage them is to, instead of having one big schema with all the object definitions inside, split it into different files containing an object definition each.
The library can handle working with complex json schemas that refer other schemas.
In order for this to work, the schemas need to have the $id
property set with the file location so it can be picked up by other files which refer to it.
Example
{
"$id": "classpath:/json1.json",
"type": "object",
"properties": {
"field1": {
"type": "string"
},
"field2": {
"type": "string"
}
"$schema": "http://json-schema.org/draft-07/schema#"
}
{
"$id": "classpath:/json2.json",
"type": "object",
"properties": {
"field1": {
"$ref": "json1.json"
}
}
"$schema": "http://json-schema.org/draft-07/schema#"
}
Stay tuned! 🚀