Logging all request mappings and their parameters in Spring
Tags: java, spring, requestmapping, applicationlistener
Published: 2020-11-21 10:30:46

Today we had a bit of a challenge ahead of our team. The guys in charge of the network and security of our client asked us to provide a list of all URLs that your application accepts, including the parameters.
Since there are tens of controllers in our application that have tens of methods inside them it was not a manual task to do anymore. After a bit of looking around, I decided to implement it using `ApplicationListener`. Any class implementing `ApplicationListener` will listen to the corresponding event and when the event occurs, fires the `onApplicationEvent` method.

For us, the event was `ContextRefreshedEvent`. I wanted to have a list of all request mappings when the application starts up and also I wanted that list updated in case of a context refresh.

So I receive the `ContextRefreshedEvent`, extract the `ApplicationContext`, get all handler methods and the request mappings, and finally extract the patterns and method parameters. This is my base implementation : 
```
@Log4j
@Component
public class EndpointsListener implements ApplicationListener<ContextRefreshedEvent> {

    @SneakyThrows
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        ApplicationContext applicationContext = event.getApplicationContext();
        List<Mapping> mappings = new ArrayList<>();
        for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : applicationContext
                .getBean(RequestMappingHandlerMapping.class).getHandlerMethods().entrySet()) {
            RequestMappingInfo requestMappingInfo = entry.getKey();
            HandlerMethod handlerMethod = entry.getValue();
            Mapping mapping = new Mapping();
            mapping.setMethods(requestMappingInfo.getMethodsCondition().getMethods()
                    .stream().map(Enum::name).collect(Collectors.toSet()));
            mapping.setPatterns(requestMappingInfo.getPatternsCondition().getPatterns());
            Arrays.stream(handlerMethod.getMethodParameters()).forEach(methodParameter -> {
                
               
                    mapping.getParams().add(methodParameter.getParameter().getType().getSimpleName());

            });
            mappings.add(mapping);
        }

        mappings.sort(Comparator.comparing(o -> o.getPatterns().stream().findFirst().orElse("")));
        log.info(new ObjectMapper().writeValueAsString(mappings));
    }

    @Data
    public class Mapping {
        private Set<String> patterns;
        private Set<String> methods;
        private List<String> params;

        public List<String> getParams(){
            if(params == null) {
                params = new ArrayList<>();
            }
            return params;

        }
    }

}
```
But there's a problem: we have quite a lot of view controllers defined in our implementation of `WebMvcConfigurer`. Let's take a look :
```
@Override
public void addViewControllers(ViewControllerRegistry registry) {
   registry.addViewController("/new").setViewName("home");
   registry.addViewController("/home").setViewName("welcome");
   registry.addViewController("/help").setViewName("help");
   registry.addViewController("/404").setViewName("error_404");
   registry.addViewController("/403").setViewName("error_403");
   registry.addViewController("/405").setViewName("error_405");
   registry.addViewController("/500").setViewName("error_500");
   
   // The list goes on for 57 more lines
}
```
These mappings will not show up in the event listener (since there's no method handler I guess). So I had to update my listener to this :
```
@Log4j
@Component
public class EndpointsListener implements ApplicationListener<ContextRefreshedEvent> {

    private final HandlerMapping viewControllerHandlerMapping;

    public EndpointsListener(HandlerMapping viewControllerHandlerMapping) {
        this.viewControllerHandlerMapping = viewControllerHandlerMapping;
    }

    @SneakyThrows
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        ApplicationContext applicationContext = event.getApplicationContext();
        List<Mapping> mappings = new ArrayList<>();
        for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : applicationContext
                .getBean(RequestMappingHandlerMapping.class).getHandlerMethods().entrySet()) {
            RequestMappingInfo requestMappingInfo = entry.getKey();
            HandlerMethod handlerMethod = entry.getValue();
            Mapping mapping = new Mapping();
            mapping.setMethods(requestMappingInfo.getMethodsCondition().getMethods()
                    .stream().map(Enum::name).collect(Collectors.toSet()));
            mapping.setPatterns(requestMappingInfo.getPatternsCondition().getPatterns());
            Arrays.stream(handlerMethod.getMethodParameters()).forEach(methodParameter -> {
                    mapping.getParams().add(methodParameter.getParameter().getType().getSimpleName());
            });
            mappings.add(mapping);
        }

        ((SimpleUrlHandlerMapping) viewControllerHandlerMapping).getHandlerMap().forEach((s, o) -> {
            Mapping mapping = new Mapping();
            mapping.setMethods(Collections.singleton("GET"));
            mapping.setPatterns(Collections.singleton(s));
            mappings.add(mapping);
        });

        mappings.sort(Comparator.comparing(o -> o.getPatterns().stream().findFirst().orElse("")));
        log.info(new ObjectMapper().writeValueAsString(mappings));
    }

    @Data
    public class Mapping {
        private Set<String> patterns;
        private Set<String> methods;
        private List<String> params;

        public List<String> getParams(){
            if(params == null) {
                params = new ArrayList<>();
            }
            return params;

        }
    }
}
```
By this, I will have a log of all the request mappings and their request parameter types. 
The code needs a bit of refactoring for sure:
* Method parameters like HttpServletRequest or HttpSession should be ignored.
* Parameter names are not logged.
* Required status of parameters is not logged.
* PathVariables should be logged separately.

The method handler could just give us parameter names like `"arg0"`, `"arg1"` and etc. But since our parameters are all annotated by `@RequestParam` we can easily get their names by getting the parameter annotation. This way we could log if the parameter is required or not and if it has a default value. The final code looks like this :

```
/**
 * A listener to listen on {@link ContextRefreshedEvent}.
 */
@Log4j
@Component
public class EndpointsListener implements ApplicationListener<ContextRefreshedEvent> {
    /**
     * Provides access to all view controllers defined in {@link SpringConfig}.
     */
    private final HandlerMapping viewControllerHandlerMapping;

    /**
     * Constructor.
     *
     * @param viewControllerHandlerMapping View Controller Handler Mapping.
     */
    public EndpointsListener(HandlerMapping viewControllerHandlerMapping) {
        this.viewControllerHandlerMapping = viewControllerHandlerMapping;
    }

    /**
     * On context refresh, get all request handler mappings and create a list.
     * Also get the view controller mappings from {@link #viewControllerHandlerMapping} and add to the list.
     *
     * @param event A {@link ContextRefreshedEvent}.
     */
    @SneakyThrows
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        ApplicationContext applicationContext = event.getApplicationContext();
        List<CustomMapping> mappings = new ArrayList<>();
        for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : applicationContext
                .getBean(RequestMappingHandlerMapping.class).getHandlerMethods().entrySet()) {
            RequestMappingInfo requestMappingInfo = entry.getKey();
            HandlerMethod handlerMethod = entry.getValue();
            CustomMapping mapping = new CustomMapping();
            mapping.setMethods(requestMappingInfo.getMethodsCondition().getMethods()
                    .stream().map(Enum::name).collect(Collectors.toSet()));
            mapping.setPatterns(requestMappingInfo.getPatternsCondition().getPatterns());
            Arrays.stream(handlerMethod.getMethodParameters()).forEach(methodParameter -> {
                CustomParameter parameter = new CustomParameter();
                Annotation[] parameterAnnotations = methodParameter.getParameterAnnotations();
                Arrays.stream(parameterAnnotations).forEach(annotation -> {
                    if (annotation instanceof PathVariable) {
                        PathVariable pathVariable = (PathVariable) annotation;
                        mapping.getPathVariables()
                                .add(new CustomPathVariable(pathVariable.name(), pathVariable.required()));
                    } else if (annotation instanceof RequestParam) {
                        RequestParam requestParam = (RequestParam) annotation;
                        parameter.setName(requestParam.name());
                        String defaultValue = requestParam.defaultValue();
                        if (!defaultValue.equals(ValueConstants.DEFAULT_NONE)) {
                            parameter.setDefaultValue(defaultValue);
                        }
                        parameter.setRequired(requestParam.required());
                        parameter.setType(methodParameter.getParameter().getType().getSimpleName());
                        mapping.getParams().add(parameter);
                    }
                });
            });
            mappings.add(mapping);
        }

        ((SimpleUrlHandlerMapping) viewControllerHandlerMapping).getHandlerMap().forEach((s, o) -> {
            CustomMapping mapping = new CustomMapping();
            mapping.setMethods(Collections.singleton("GET"));
            mapping.setPatterns(Collections.singleton(s));
            mappings.add(mapping);
        });

        mappings.sort(Comparator.comparing(o -> o.getPatterns().stream().findFirst().orElse("")));
        log.info(new ObjectMapper().writeValueAsString(mappings));
    }

    /**
     * Custom mapping class.
     */
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    @Data
    public class CustomMapping {
        private Set<String> patterns;
        private Set<String> methods;
        private List<CustomParameter> params = new ArrayList<>();
        private List<CustomPathVariable> pathVariables = new ArrayList<>();
    }

    /**
     * Custom Parameter class
     */
    @JsonInclude(JsonInclude.Include.NON_NULL)
    @Data
    public class CustomParameter {
        private String name;
        private String type;
        private String defaultValue;
        private Boolean required;
    }

    /**
     * Custom path variable class.
     */
    @JsonInclude(JsonInclude.Include.NON_NULL)
    @AllArgsConstructor
    @Data
    public class CustomPathVariable {
        private String name;
        private Boolean required;
    }
}

```
But what if they are changed? What if we add a new controller the day after we declare our URLs to the security guys? So instead of logging the results, I will keep them as a list and write a rest controller for accessing them.

The code is also available as a [gist](https://gist.github.com/amaralani/2e310d0f2855c8f0a6c50724e5cab03a).