I'm developing a Spring boot with Spring MVC web app using rest controllers. I'd like to redirect URLs that have empty parameters to the same URL but without its parameters; the idea being to explicitly remove the semantic ambiguity between an “empty filter” and “no filter”. I'm using the Links to Controllers to do this.

Sample:

@RestController
@RequestMapping(path = "/foo")
public class MyRestController {
    @GetMapping(path = "/bar")
    public ResponseEntity<List<Integer>> search(@ModelAttribute @NotNull BarRequest barRequest, @RequestParam(defaultValue = "10") Integer limit) {
        final BarRequest disambiguatedBarRequest = barRequest.disambiguated();
        if (barRequest != disambiguatedBarRequest) {
            return ResponseEntity
                    .status(HttpStatus.PERMANENT_REDIRECT)
                    .header(HttpHeaders.LOCATION, MvcUriComponentsBuilder
                            .fromMethodName(this.getClass(), "search", disambiguatedBarRequest, limit).build()
                            .encode().toUri().toASCIIString())
                    .build();
        }

        return ResponseEntity.ok(generateRandomArray(limit, 0, 100));
    }

    // credits: Ashish Lahoti
    public static List<Integer> generateRandomArray(int size, int min, int max) {
        return IntStream
                .generate(() -> min + new Random().nextInt(max - min + 1))
                .limit(size).boxed().toList();
    }

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    public static class BarRequest {
        private Set<String> categories;
        private Set<String> otherFilters;

        public BarRequest disambiguated() {
            BarRequest candidate = BarRequest.builder()
                    .categories(disambiguateStringSet(categories))
                    .otherFilters(disambiguateStringSet(otherFilters))
                    .build();
            if (Objects.equals(candidate, this)) {
                return this;
            } else {
                return candidate;
            }
        }

        private Set<String> disambiguateStringSet(Set<String> set) {
            return Optional.ofNullable(set)
                    .map(categories -> categories.stream().filter(Objects::nonNull).collect(Collectors.toSet()))
                    .filter(c -> !c.isEmpty())
                    .orElse(null);
        }
    }
}

In this example, the URI /foo/bar?categories=not_empty&otherFilters=&limit=3 is expected to result in a redirect to /foo/bar?categories=not_empty&limit=3 but I am redirected to /foo/bar?limit=3 instead.

The problem comes from MvcUriComponentsBuilder which only prepares the url for “simple” method parameters (Integer, String, Set).

@jcagarcia explains well how MvcUriComponentsBuilder works in this post and it allows us to to identify how these "contributors" are chosen and used (CompositeUriComponentsContributor):

@Override
public void contributeMethodArgument(
        MethodParameter parameter, Object value,
        UriComponentsBuilder builder, 
        Map<String, Object> uriVariables, 
        ConversionService conversionService) {

    for (Object contributor : this.contributors) {
        if (contributor instanceof UriComponentsContributor) {
            UriComponentsContributor ucc = (UriComponentsContributor) contributor;
            if (ucc.supportsParameter(parameter)) {
                ucc.contributeMethodArgument(parameter, value, builder, uriVariables, conversionService);
                break;
            }
        }
        else if (contributor instanceof HandlerMethodArgumentResolver) {
            if (((HandlerMethodArgumentResolver) contributor).supportsParameter(parameter)) {
                break;
            }
        }
    }
}

In my example, it is contributor ServletModelAttributeMethodProcessor who is chosen but as he does not implement UriComponentsContributor then MvcUriComponentsBuilder will simply ignore these parameters, resulting in an incomplete URL.

You will find a ready-to-run example on this repository.

Comment From: rstoyanchev

Currently we don't support use of form-back objects from a client perspective for sending requests. This is just one of a number of instances with similar requirements. So this is related to #32142, and possible depends on it, but either way the need to be considered together.

A specific challenge with this is that if the model attribute BarRequest could have additional properties not intended to appear as query params.