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=®ister=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 action
s.
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 RequestMappingHandlerMapping
s’ 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