Legacy migration: custom request handling in Spring Web

Using annotation-based Spring configuration

Intro

If one day you woke up with the feeling, that the standard Spring Web handlers are lacking functionality and you want to extend it yourself, then look no further. This article is for you, and I hope it will be helpful.

Example

Consider the scenario: you have an old legacy project full of heavy domain logic and questionable technical solutions. Logic is grouped by so called modules, each module may have one or several action. On a frontend, client submits form by clicking one of several submit buttons - this initiates a POST request. Each form has a hidden field moduleId which determines which module has to handle that request. Which action needs to be triggered is determined as follows: each field from the request is checked against registered actions of module; if a name of a field equals to the action name - then we trigger that action, otherwise, default action is taken.

UserModule(id="service.user.module")
> default
> register
> forgotPassword
<form method="POST" action="/">
  <input type="hidden" name="moduleId" value="service.user.module"/>
  <input type="text" name="login"/>
  <input type="password" name="password"/>
  <br/>
  <input type="submit" name="default" value="Login"/>
  <input type="submit" name="register" value="Register"/>
  <input type="submit" name="forgotPassword" value="Forgot password?"/>
</form>

Upon clicking one of the buttons, following request may be sent:

  • Method: POST URL: / Form data: moduleId=service.user.module&username=&password=&default=Login
  • Method: POST URL: / Form data: moduleId=service.user.module&username=&password=&register=Register
  • Method: POST URL: / Form data: moduleId=service.user.module&username=&password=&forgotPassword=Forgot+password%3F

We want to migrate this functionality to Spring, which means that we need a custom type of Controller, which would be identified by moduleId, rather than a RequestMapping, and have handling methods identified as actions.

One note - the legacy framework wasn’t permitting sending forms with field names being equal to a possible action within a module. You can’t name action “login” in the above example, as This is a limitation we inherit and don’t care about, as our goal is to migrate old functionality to a new service for future transformation, not to extend and develop it

Implementation

Let’s begin with a boring part and create our annotations that we we will use to annotate controllers and actions.

// LegacyModule.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface LegacyModule {
  String moduleId();
}
// LegacyAction.java
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface LegacyAction {
  String value() default ""; // Use annotated method name if not set
}

HandlerMapping

Now, we need to implement a handler. Base interface is HandlerMapping, but that’s way too low level for what we need to do. By default Spring uses RequestMappingInfoHandlerMapping which works with RequestMappingInfo. I got bored reading through half of RequestMappingInfos’ properties and it’s obvious that it’s not a data class we need for our use case. Let’s create our own instead!

// LegacyMapping.java
@Value // I use Lombok, you may get rid of those two annotations and generate constructors, getters and setters instead
@AllArgsConstructor
public class LegacyMapping {
  String action;
  String controller;
}

Now, we need to implement class extending AbstractHandlerMethodMapping:

public class LegacyHandlingMapper extends AbstractHandlerMethodMapping<LegacyMapping> {
  public static final String DEFAULT_ACTION = "default";
  public static final String MODULE_PARAMETER = "moduleId";


  @Override
  protected boolean isHandler(Class<?> beanType) {
    return AnnotatedElementUtils.hasAnnotation(beanType, LegacyModule.class);
  }

  @Override
  protected LegacyMapping getMappingForMethod(Method method, Class<?> handlerType) {
    if (AnnotatedElementUtils.isAnnotated(method, LegacyAction.class)) {
      LegacyModule module = AnnotatedElementUtils.findMergedAnnotation(method.getDeclaringClass(), LegacyModule.class);
      LegacyAction action = AnnotatedElementUtils.getMergedAnnotation(method, LegacyAction.class);
      if (action != null && module != null) {
        var actionName = (action.value().isBlank()) ? method.getName() : action.value();
        return new LegacyMapping(actionName, module.moduleId());
      }
    }
    return null;
  }

  @Override
  protected LegacyMapping getMatchingMapping(LegacyMapping mapping, HttpServletRequest request) {
    // Any magic you might want to have should go here
    // For now we'll just compare mapping with request parameters
    if ("POST".equals(request.getMethod())) {
      // If it's a form submission
      if (mapping.getModule().equals(request.getParameter(MODULE_PARAMETER))) {
        // If mappings' module matches provided in request parameter
        var isDefault = DEFAULT_ACTION.equals(mapping.getAction());
        var actionInParameters = request.getParameter(mapping.getAction()) != null;
        if (isDefault || actionInParameters) {
          return mapping;
        }
      }
    }
    return null;
  }

  @Override
  protected Comparator<LegacyMapping> getMappingComparator(HttpServletRequest request) {
    // default actions have least priority, otherwise we just compare alphabetically  
    return (c1, c2) -> {
      if (DEFAULT_ACTION.equals(c1.getAction())) {
        return -1;
      } else if (DEFAULT_ACTION.equals(c2.getAction())) {
        return 1;
      } else {
        return c1.getAction().compareTo(c2.getAction());
      }
    };
    
  }
}

DelegatingWebMvcConfiguration

Finally, to make it all work - get rid of @EnableWebMvc annotation, and create a @Configuration class that would extend DelegatingWebMvcConfiguration (a class that is imported from EnableWebMvc annotation). In here we need to create a bean for LegacyHandlingMapper and make it’s order lower than zero, because RequestMappingHandlerMappings’ order starts at 0. We can make it as follows:

@Configuration
public class WebConfig extends DelegatingWebMvcConfiguration {

  @Bean
  public LegacyHandlingMapper legacyHandlingMapper() {
    var mapper = new LegacyHandlingMapper();
    mapper.setOrder(-1);
    return mapper;
  }
}

And, that’s it! You’ll need to create a controller, annotate it with @LegacyModule annotation, add @LegacyAction to controller methods. Spring will pick up the rest for you - and call those methods as normal handler methods.

Hope this was helpful!

P.S.

This article will be updated