in spring annotation request param sign plus ~ de lecture.

Création d'une annotation Spring personnalisée

Je me suis heurté récemment à un problème d'encoding lié à l'annotation @RequestParam de Spring qui permet de récupérer simplement un paramètre de requête HTTP.
Cette annotation exploite naturellement la méthode getParameter() de l'objet HttpServletRequest qui extrait la valeur des paramètres de l'URL en la "décodant" par le biais de la classe URLDecoder, dont l'une de ses règles est la suivante :

The plus sign "+" is converted into a space character " " .

C'est là que le problème est apparu, car, vous l'aurez compris, l'un des paramètres d'une de mes ressources REST utilise abondamment le caractère "+".

Cas d'utilisation

Voici la ressource REST en question :

/**
  * -> GET /pages?url=/
  * -> GET /pages?url=/une+url+contenant+des+plus
  */
@RequestMapping(value = "/pages", method = RequestMethod.GET)
    public ResponseEntity<Page> getPage(@RequestParam(name = "url", required = true) String url) {

        return pageService.getPage(url)
                .map(page -> new ResponseEntity<>(page, HttpStatus.OK))
                .orElseThrow(() -> new SomeApiRuntimeException("short_desc", "long_desc"));
    }

Si nous appelons cette ressource de la manière suivante :

GET /pages?url=/aaa+bbb-ccc/xxx+zzz

Nous récupérons la valeur "aaa bbb-ccc/xxx zzz" dans la variable url de la ressource, nous avons donc perdu les caractères "+".

Contournements envisagés

Voici les quatre pistes qui ont été envisagées pour résoudre ce problème :

  • Encoder les paramètres d'url en amont afin que le décodage natif via @RequestParam opère sans poser de problème :
GET "/pages?url=" + encodeURIComponent("/aaa+bbb-ccc/xxx+zzz")

Cette solution ne me plait pas car elle impose une contrainte à tous les clients consommant cet API (Postman, RestTemplate, $.get ...).

  • Changer le comportement natif de l'annotation @RequestParam via de la configuration Java : je n'ai rien trouvé me permettant de faire cela, cependant même si ce moyen existe, je ne l'aurai pas utilisé de peur de son comportement régressif dans certaines situations.

  • Récupérer les caractères "+" perdus en les rajoutant à la main ou en réencodant la variable après coup :

@RequestMapping(value = "/pages", method = RequestMethod.GET)
    public ResponseEntity<Page> getPage(@RequestParam(name = "url", required = true) String url) {

        url = retrieveLostPlusSign(url);

        ...
    }

Cette solution non plus n'a pas été retenue, je ne trouve pas cela élégant, de plus cela m'oblige à reporter ce bout de code un peu partout dans mon API.

  • Créer une annotation personnalisée afin de l'utiliser à la place de @RequestParam : c'est la solution que j'ai choisi, élégant, propre, efficace et transportable.

Annotation personnalisée

Voici les différentes classes à créer et à configurer afin de pouvoir remplacer l'annotation @RequestParam par @RequestParamRaw qui permet de ne pas perdre les caractères "+".

  • RequestParamRaw

Copier-coller du source de l'annotation de Spring @RequestParam.

import org.springframework.core.annotation.AliasFor;
import org.springframework.web.bind.annotation.ValueConstants;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface RequestParamRaw {

    /**
     * Alias for {@link #name}.
     */
    @AliasFor("name") String value() default "";

    /**
     * The name of the request parameter to bind to.
     *
     * @since 4.2
     */
    @AliasFor("value") String name() default "";

    /**
     * Whether the parameter is required.
     * <p>Defaults to {@code true}, leading to an exception being thrown
     * if the parameter is missing in the request. Switch this to
     * {@code false} if you prefer a {@code null} value if the parameter is
     * not present in the request.
     * <p>Alternatively, provide a {@link #defaultValue}, which implicitly
     * sets this flag to {@code false}.
     */
    boolean required() default true;

    /**
     * The default value to use as a fallback when the request parameter is
     * not provided or has an empty value.
     * <p>Supplying a default value implicitly sets {@link #required} to
     * {@code false}.
     */
    String defaultValue() default ValueConstants.DEFAULT_NONE;
}

  • RequestParamRawUtil

Voici la classe utilitaire qui va permettre d'extraire tels quels les paramètres envoyés dans la requête HTTP, sans traitement superflu, en travaillant à partir de l'information fournie par la méthode getQueryString() de la classe HttpServletRequest.

import org.apache.commons.collections4.CollectionUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import java.util.List;
import java.util.stream.Stream;

@Component
public class RequestParamRawUtil {

    /**
     * Récupère la valeur du paramètre souhaité à partir de la queryString sans traitement particulier
     */
    public String getRawParameterValue(String attrName, String queryString) {
        MultiValueMap<String, String> requestParameterMap = getRequestParameterMap(queryString);
        List<String> parameterValueList = requestParameterMap.get(attrName);

        return CollectionUtils.isEmpty(parameterValueList) ? "" : parameterValueList.get(0);
    }

    /**
     * On récupère la map des paramètres sans l'urlencode fait nativement par HttpServletRequest.getParameterMap()
     *
     * "url=/femme-pret+a+porter&fields=template,footer,header"
     * ->
     * url : /femme-pret+a+porter
     * fields : template,footer,header
     */
    public MultiValueMap<String, String> getRequestParameterMap(String requestQueryString) {
        MultiValueMap<String, String> requestParameterMap = new LinkedMultiValueMap<>();

        String[] params = requestQueryString.split("&");
        Stream.of(params).forEach(param -> {
            String[] keyValue = param.split("=");
            if (keyValue.length == 1) {
                requestParameterMap.add(keyValue[0], "");
            } else if (keyValue.length == 2) {
                requestParameterMap.add(keyValue[0], keyValue[1]);
            }
        });

        return requestParameterMap;
    }
}
  • RequestParamRawResolver

Cette classe est la définition de notre annotation, elle définie son nom et ce qu'elle fait. Bien entendu elle utilise notre précédente classe RequestParamRawUtil qui contient la logique métier.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.servlet.http.HttpServletRequest;

public class RequestParamRawResolver implements HandlerMethodArgumentResolver {

    @Autowired
    private RequestParamRawUtil requestParamRawUtil;

    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterAnnotation(RequestParamRaw.class) != null;
    }

    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) throws Exception {
        RequestParamRaw attr = parameter.getParameterAnnotation(RequestParamRaw.class);
        HttpServletRequest nativeRequest = (HttpServletRequest) webRequest.getNativeRequest();

        return requestParamRawUtil.getRawParameterValue(attr.name(), nativeRequest.getQueryString());
    }
}
  • @Configuration

Voici la configuration Spring qui va nous permettre d'utiliser notre nouvelle annotation au sein de notre application Spring.

@Configuration
public class WebConfigurer extends WebMvcConfigurerAdapter implements ServletContextInitializer {

    ...

    /**
     * Resolvers
     */
    @Bean
    public RequestParamRawResolver requestParamRawResolver() {
        return new RequestParamRawResolver();
    }

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(requestParamRawResolver());
    }

}

Résultat

Un seul mot a changé par rapport à la classe d'origine (@RequestParamRaw) et tout fonctionne correctement :-)

/**
  * -> GET /pages?url=/
  * -> GET /pages?url=/une+url+contenant+des+plus
  */
@RequestMapping(value = "/pages", method = RequestMethod.GET)
    public ResponseEntity<Page> getPage(@RequestParamRaw(name = "url", required = true) String url) {

        return pageService.getPage(url)
                .map(page -> new ResponseEntity<>(page, HttpStatus.OK))
                .orElseThrow(() -> new SomeApiRuntimeException("short_desc", "long_desc"));
    }