Dynamic Reponse Body Attribute Filtering

Response body attribute filtering is a nice feature to have when creating REST APIs. You enable the client to choose which attributes should be returned in the HTTP response.

Let's say you have an endpoint /users that returns a list of users when a GET request is sent:

[
  {
    "id": 1,
    "firstName": "John",
    "lastName": "Doe"
  },
  {
    "id": 2,
    "firstName": "Jane",
    "lastName": "Doe"
  }
]

Then you want to filter the response attributes using a query string like: /users?fields=id,firstName that will produce:

[
  {
    "id": 1,
    "firstName": "John"
  },
  {
    "id": 2,
    "firstName": "Jane"
  }
]

Luckily, dynamic response body attribute filtering is really easy to achieve and requires a minimal setup when using Spring framework.

We can make use of JsonFilter and AbstractMappingJacksonResponseBodyAdvice to implement a dynamic response body filter.

So let's implement a filter composed of:

You can find the source code of a demo here.

Dependencies

The required dependencies to add to the project's pom.xml file:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

Implementation

Let's start by creating an annotation that will enable response body attribute filtering whenever is placed on a request handler method:

import static com.example.demo.ResponseBodyFilterAdvice.*;

@Documented
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface ResponseBodyFilter {

    String filterOut() default "fields";

    String exclude() default "exclude";

    String delimiter() default "\\s*,\\s*";

    String filter() default DEFAULT_RESPONSE_BODY_FILTER;
}

We also need a controller advice that will apply the filtering:

@ControllerAdvice
public class ResponseBodyFilterAdvice extends AbstractMappingJacksonResponseBodyAdvice {

    public static final String DEFAULT_RESPONSE_BODY_FILTER = "RESPONSE_BODY_FILTER";

    private final SimpleFilterProvider filterProvider = new SimpleFilterProvider();

    private final String[] EMPTY_FIELDS = new String[]{};

    @Override
    protected void beforeBodyWriteInternal(MappingJacksonValue container,
                                           MediaType mediaType,
                                           MethodParameter methodParameter,
                                           ServerHttpRequest request,
                                           ServerHttpResponse response) {
        FilterProvider filters = container.getFilters();
        ResponseBodyFilter responseBodyFilter = methodParameter.getMethodAnnotation(ResponseBodyFilter.class);

        if (Objects.isNull(responseBodyFilter) || Objects.nonNull(filters)) {
            return;
        }

        HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
        String filterOutParameter = responseBodyFilter.filterOut();
        String excludeParameter = responseBodyFilter.exclude();
        String delimiter = responseBodyFilter.delimiter();

        String[] filterOutFields = parseFilterAttribute(servletRequest, filterOutParameter, delimiter);
        String[] excludeFields = parseFilterAttribute(servletRequest, excludeParameter, delimiter);
        PropertyFilter filter = getPropertyFilter(filterOutFields, excludeFields);

        filterProvider.addFilter(responseBodyFilter.filter(), filter);
        container.setFilters(filterProvider);
    }

    private PropertyFilter getPropertyFilter(String[] filterOut, String[] exclude) {
        if (exclude.length > 0) {
            return SimpleBeanPropertyFilter.serializeAllExcept(exclude);
        }

        if (filterOut.length > 0) {
            return SimpleBeanPropertyFilter.filterOutAllExcept(filterOut);
        }

        return SimpleBeanPropertyFilter.serializeAll();
    }

    private String[] parseFilterAttribute(HttpServletRequest servletRequest, String parameter, String delimiter) {
        String value = servletRequest.getParameter(parameter);
        if (Objects.isNull(value)) {
            return EMPTY_FIELDS;
        }

        String[] attributes = value.split(delimiter);
        String[] fields = Arrays.stream(attributes).filter(e -> !e.isEmpty()).toArray(String[]::new);

        return fields.length == 0
                ? EMPTY_FIELDS
                : fields;
    }

}

The required setup to filter response attributes is done. Let's create a controller and a response entity to test the filter:

import static com.example.demo.ResponseBodyFilterAdvice.*;

@JsonFilter(DEFAULT_RESPONSE_BODY_FILTER)
public class UserResponse {

    private String id;
    private String firstName;
    private String lastName;

    // getters and setters
}
@RestController
@RequestMapping("/users")
public class UserController {

    private final List<UserResponse> users = createUsers();

    @GetMapping
    @ResponseBodyFilter
    public List<UserResponse> getAllUsers() {
        return users;
    }

    private List<UserResponse> createUsers() {
        UserResponse john = new UserResponse();
        john.setId(UUID.randomUUID().toString());
        john.setFirstName("John");
        john.setLastName("Doe");

        UserResponse jane = new UserResponse();
        jane.setId(UUID.randomUUID().toString());
        jane.setFirstName("Jane");
        jane.setLastName("Doakes");

        return Arrays.asList(john, jane);
    }

}

Finally, we can create the main class to run the project:

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

}

Once the application is running we can send a GET request to the endpoint /users, the following response is expected:

[
  {
    "id": "709a6c69-b025-4007-be1a-73eb2c7de6fe",
    "firstName": "John",
    "lastName": "Doe"
  },
  {
    "id": "22fb3b32-9bcb-498b-b750-4b170aad56a9",
    "firstName": "Jane",
    "lastName": "Doakes"
  }
]

We can then filter the attributes by sending a query string with the attributes fields or exclude:

http://localhost:8080/users?exclude=id
[
  {
    "firstName": "John",
    "lastName": "Doe"
  },
  {
    "firstName": "Jane",
    "lastName": "Doakes"
  }
]
http://localhost:8080/users?fields=id,firstName
[
  {
    "id": "709a6c69-b025-4007-be1a-73eb2c7de6fe",
    "firstName": "John"
  },
  {
    "id": "22fb3b32-9bcb-498b-b750-4b170aad56a9",
    "firstName": "Jane"
  }
]

You can also replace the request attributes used to filter the response:

@ResponseBodyFilter(
  filterOut = "include", 
  exclude = "remove", 
  delimiter = "\\|", 
  filter = "someOtherFilterName"
)

The example above will look for the parameters include and remove to filter attributes separated by |(%7C):

/users?include=lastName%7CfirstName
/users?remove=id