mscharhag, Programming and Stuff;

A blog about programming and software development topics, mostly focused on Java technologies including Java EE, Spring and Grails.

Monday, 3 August, 2020

Integrating JSON Schema validation in Spring using a custom HandlerMethodArgumentResolver

In previous posts we learned about JSON Schema and how we can validate a JSON document against a JSON Schema in Java. In this post we will integrate JSON Schema validation into a Spring Boot application using a custom HandlerMethodArgumentResolver. We will use the same JSON document and JSON Schema as in previous posts.

So, what is a HandlerMethodArgumentResolver?

Handler methods in Spring controllers (= methods annotated with @RequestMapping, @GetMapping, etc.) have flexible method signatures. Depending on what is needed inside a controller method, various method arguments can be added. Examples are request and response objects, headers, path variables or session values. Those arguments are resolved using HandlerMethodArgumentResolvers. Based on the argument definition (type, annotations, etc.) a HandlerMethodArgumentResolver is responsible for obtaining the actual value that should be passed to the controller.

A few standard HandlerMethodArgumentResolvers provided by Spring are:

  • PathVariableMethodArgumentResolver resolves arguments annotated with @PathVariable.
  • Request related method arguments like WebRequest, ServletRequest or MultipartRequest are resolved by ServletRequestMethodArgumentResolve.
  • Arguments annotated with @RequestHeader are resolved by RequestHeaderMapMethodArgumentResolver.

In the following we will create our own HandlerMethodArgumentResolver implementation that validates a JSON request body against a JSON Schema before the JSON data is passed to a controller method.

Getting started

Like in the previously mentionend article about JSON Schema validation in Java we will use the json-schema-validator library:

<dependency>
    <groupId>com.networknt</groupId>
    <artifactId>json-schema-validator</artifactId>
    <version>1.0.42</version>
</dependency>

We start with creating our own @ValidJson annotation. This annotation will be used to mark controller method arguments that should be resolved by our own HandlerMethodArgumentResolver.

@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidJson {
    String value();
}

As we see in the next snippet, the value parameter of our @ValidJson annotation is used to define the path to the JSON Schema. We can now come up with the following controller implementation:

public class Painting {
    private String name;
    private String artist;

   // more fields, getters + setters
}
public interface SchemaLocations {
    String PAINTING = "classpath:painting-schema.json";
}
@RestController
public class PaintingController {

    @PostMapping("/paintings")
    public ResponseEntity<Void> createPainting(@ValidJson(PAINTING) Painting painting) {
        ...
    }
}

Painting is a simple POJO we use for JSON mapping. SchemaLocations contains the location of our JSON Schema documents. In the handler method createPainting we added a Painting argument annotated with @ValidJson. We pass the PAINTING constant to the @ValidJson annotation to define which JSON Schema should be used for validation.

Implementing HandlerMethodArgumentResolver

HandlerMethodArgumentResolver is an interface with two methods:

public interface HandlerMethodArgumentResolver {

    boolean supportsParameter(MethodParameter parameter);

    Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception;
}

supportsParameter(..) is used to check if this HandlerMethodArgumentResolver can resolve a given MethodParameter, while resolveArgument(..) resolves and returns the actual argument.

We implement this interface in our own class: JsonSchemaValidatingArgumentResolver:

public class JsonSchemaValidatingArgumentResolver implements HandlerMethodArgumentResolver {

    private final ObjectMapper objectMapper;
    private final ResourcePatternResolver resourcePatternResolver;
    private final Map<String, JsonSchema> schemaCache;

    public JsonSchemaValidatingArgumentResolver(ObjectMapper objectMapper, ResourcePatternResolver resourcePatternResolver) {
        this.objectMapper = objectMapper;
        this.resourcePatternResolver = resourcePatternResolver;
        this.schemaCache = new ConcurrentHashMap<>();
    }

    @Override
    public boolean supportsParameter(MethodParameter methodParameter) {
        return methodParameter.getParameterAnnotation(ValidJson.class) != null;
    }
    
    ...
}

supportsParameter(..) is quite easy to implement. We simply check if the passed MethodParameter is annotated with @ValidJson.

In the constructor we take a Jackson ObjectMapper and a ResourcePatternResolver. We also create a ConcurrentHashMap that will be used to cache JsonSchema instances.

Next we implement two helper methods: getJsonPayload(..) returns the JSON request body as String while getJsonSchema(..) returns a JsonSchema instance for a passed schema path.

private String getJsonPayload(NativeWebRequest nativeWebRequest) throws IOException {
    HttpServletRequest httpServletRequest = nativeWebRequest.getNativeRequest(HttpServletRequest.class);
    return StreamUtils.copyToString(httpServletRequest.getInputStream(), StandardCharsets.UTF_8);
}

private JsonSchema getJsonSchema(String schemaPath) {
    return schemaCache.computeIfAbsent(schemaPath, path -> {
        Resource resource = resourcePatternResolver.getResource(path);
        if (!resource.exists()) {
            throw new JsonSchemaValidationException("Schema file does not exist, path: " + path);
        }
        JsonSchemaFactory schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V201909);
        try (InputStream schemaStream = resource.getInputStream()) {
            return schemaFactory.getSchema(schemaStream);
        } catch (Exception e) {
            throw new JsonSchemaValidationException("An error occurred while loading JSON Schema, path: " + path, e);
        }
    });
}

A JsonSchema is retrieved from a Spring Resource that is obtained from a ResourcePatternResolver. JsonSchema instances are cached in the previously created Map. So a JsonSchema is only loaded once. If an error occurs while loading the JSON Schema, a JsonSchemaValidationException is thrown.

The last step is the implementation of the resolveArgument(..) method:

@Override
public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
    // get schema path from ValidJson annotation
    String schemaPath = methodParameter.getParameterAnnotation(ValidJson.class).value();

    // get JsonSchema from schemaPath
    JsonSchema schema = getJsonSchema(schemaPath);

    // parse json payload
    JsonNode json = objectMapper.readTree(getJsonPayload(nativeWebRequest));

    // Do actual validation
    Set<ValidationMessage> validationResult = schema.validate(json);

    if (validationResult.isEmpty()) {
        // No validation errors, convert JsonNode to method parameter type and return it
        return objectMapper.treeToValue(json, methodParameter.getParameterType());
    }

    // throw exception if validation failed
    throw new JsonValidationFailedException(validationResult);
}

Here we first get the location of the JSON Schema from the annotation and resolve it to an actual JsonSchema instance. Next we parse the request body to a JsonNode and validate it using the JsonSchema. If validation errors are present we throw a JsonValidationFailedException.

You can find the source code for the complete class on GitHub.

Registering our HandlerMethodArgumentResolver

Next we need to tell Spring about our JsonSchemaValidatingArgumentResolver. We do this by using the addArgumentResolvers(..) method from the WebMvcConfigurer interface.

@Configuration
public class JsonValidationConfiguration implements WebMvcConfigurer {

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private ResourcePatternResolver resourcePatternResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new JsonSchemaValidatingArgumentResolver(objectMapper, resourcePatternResolver));
    }
}

Error handling

In our JsonSchemaValidatingArgumentResolver we throw two different exceptions:

  • JsonSchemaLoadingFailedException if loading the JSON Schema fails
  • JsonValidationFailedException if the JSON validation fails

JsonSchemaLoadingFailedException very likely indicates a programming error while a JsonValidationFailedException is caused by a client sending an invalid JSON document. So we should clearly send a useful error message to the client if the later occurs.

We do this by using an @ExceptionHandler method in a class annotated with @ControllerAdvice:

@ControllerAdvice
public class JsonValidationExceptionHandler {

    @ExceptionHandler(JsonValidationFailedException.class)
    public ResponseEntity<Map<String, Object>> onJsonValidationFailedException(JsonValidationFailedException ex) {
        List<String> messages = ex.getValidationMessages().stream()
                .map(ValidationMessage::getMessage)
                .collect(Collectors.toList());
        return ResponseEntity.badRequest().body(Map.of(
            "message", "Json validation failed",
            "details", messages
        ));
    }
}

Within the method we retrieve the validation messages from the passed exception and send them to the client.

Example request

An example request we can send to our PaintingController looks like this:

POST http://localhost:8080/paintings
Content-Type: application/json

{
  "name": "Mona Lisa",
  "artist": null,
  "description": null,
  "dimension": {
    "height": -77,
    "width": 53
  },
  "tags": [
    "oil",
    "famous"
  ]
}

Note that the JSON body contains two errors: artist is not allowed to be null and the painting height is negative. (You can look at the JSON Schema on GitHub)

Our server responds to this request with the following response:

HTTP/1.1 400 (Bad Request)
{
  "message": "Json validation failed",
  "details": [
    "$.artist: null found, string expected",
    "$.dimension.height: must have a minimum value of 1"
  ]
}

Summary

We learned how to integrate JSON Schema validation into a Spring Boot application by implementing a custom HandlerMethodArgumentResolver. Within our implementation we validate the JSON request body against a JSON Schema before it is passed as argument to the controller. To return proper error message we can add a @ExceptionHandler method.

You can find the complete example project on GitHub.

Comments

  • TrueVultureBare - Thursday, 17 September, 2020

    Thanks!

  • alinmtv - Monday, 18 January, 2021

    Excellent example. Thank you!

Leave a reply