DEV Community

loading...
Cover image for Logging all request mappings and their parameters in Spring

Logging all request mappings and their parameters in Spring

amaralani profile image Amir Maralani Updated on ・4 min read

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;

        }
    }

}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;

        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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.

Discussion (3)

pic
Editor guide
Collapse
verley93 profile image
Devlin Verley II

You may be able to consider OpenAPI a scalable alternative. See below for more details.

build.gradle

implementation 'org.springdoc:springdoc-openapi-ui:1.5.0'
Enter fullscreen mode Exit fullscreen mode

OR

pom.xml

   <dependency>
      <groupId>org.springdoc</groupId>
      <artifactId>springdoc-openapi-ui</artifactId>
      <version>1.5.0</version>
   </dependency>
Enter fullscreen mode Exit fullscreen mode

Then you automagically gain access to the Swagger UI and the API Docs, which may be accessed (while the app is running) at server:port/swagger-ui.html and server:port/v3/api-docs.

The Swagger UI provides a friendly interface for observing controllers, their endpoints, fields, parameters, bodies, and even sample requests.

Swagger UI Example

The API Docs might be closer to what you are looking for with the above details organized into a JSON structure. It's likely you would be able to send them a link to your API Docs so long as the app is running somewhere.

API Docs Example

You can take this many steps further and annotate specific fields of your request models with examples, annotate controller methods with possible responses, and so forth, all of which propagate to the API Docs and Swagger UI.

Hope this helps, and makes life at work a bit easier!

Collapse
amaralani profile image
Amir Maralani Author

A really good suggestion! Thanks.
Although we have some restrictions about implementing this, it would be a good alternative in other cases.

Collapse
verley93 profile image
Devlin Verley II

That's unfortunate! Our team also has restrictions on using Open API, but we're still able to use Swagger 2.0. Less automation but still does what we need.

Best of luck out there!