1. Overview

In this tutorial, we’ll explore the changes in URL-matching introduced by Spring Boot 3 (Spring 6). URL matching is a powerful feature in Spring Boot that enables developers to map specific URLs to controllers and actions in a web application. This feature enables the easy organization and navigation of the application, leading to a better user experience.

To handle URL mapping, Spring Boot uses a powerful mechanism called the DispatcherServlet, which acts as the front controller for the application. The servlet forwards requests to the appropriate controller based on the URL. The DispatcherServlet uses a set of rules, known as mappings, to determine which controller should handle a given request.

We can start by reading more details about an older version of URL matching in Spring Boot 2 (Spring 5).

2. Spring MVC and Webflux URL Matching Changes

Spring Boot 3 significantly changed the trailing slash matching configuration option. This option determines whether or not to treat a URL with a trailing slash the same as a URL without one. Previous versions of Spring Boot set this option to true by default. This meant that a controller would match both “GET /some/greeting” and “*GET /some/greeting/*” by default:

@RestController
public class GreetingsController {

    @GetMapping("/some/greeting")
    public String greeting {
        return "Hello";
    } 

}

As a result, if we try to access a URL with a trailing slash, we will receive a 404 error unless the controller is specifically set up to handle URLs with a trailing slash. This can lead to confusion and broken links if not properly handled.

Let’s explore some options on how we can adapt to this change.

3. Additional Route

Let’s update our existing applications to ensure URLs are handled correctly to accommodate this change. This can be done by adding specific URL mappings for each controller that handles URLs with a trailing slash.

For example, in our previous controller that handles the URL “*/some/greeting“, we need to add a separate mapping for “/some/greeting/*“. This will ensure that users can access the desired page even if they include a trailing slash in the URL.

@RestController
public class GreetingsController {

    @GetMapping("/some/greeting")
    public String greeting {
        return "Hello";
    } 

    @GetMapping("/some/greeting/")
    public String greeting {
        return "Hello";
    } 

}

Here is a reactive @RestController using Webflux:

@RestController
public class GreetingsControllerReactive {

    @GetMapping("/some/reactive/greeting")
    public Mono<String> greeting() {
        return Mono.just("Hello reactive");
    }

    @GetMapping("/some/reactive/greeting/")
    public Mono<String> greetingTrailingSlash() {
        return Mono.just("Hello with slash reactive");
    }
}

4. Override Default Configuration

It is easier to override the trailing slash matching configuration and set it explicitly to true. We can do this by overriding Spring MVC’s WebMvcConfigurer:configurePathMatch method:

@Configuration
public class WebConfiguration implements WebMvcConfigurer {

    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
      configurer.setUseTrailingSlashMatch(true);
    }
}

If we use Webflux, the configuration change is similar:

@Configuration
class WebConfiguration implements WebFluxConfigurer {

    @Override
    public void configurePathMatching(PathMatchConfigurer configurer) {
        configurer.setUseTrailingSlashMatch(true);
    }
}

This would grant us backward compatibility until we fully adapt our application.

5. Configure Redirect Using a Custom Filter

When a request comes in, the Spring Boot filter chain determines which filters to apply based on the request and the registered filters. If the request is received by a traditional blocking I/O endpoint, such as a @RestController or a @Controller, the filter chain will apply any filters that implement the Filter interface. These filters block I/O and may interact with the Servlet API to read or modify the request or response.

To configure an URL redirect using a custom filter for blocking requests, we can follow these steps:

Firstly, we need to create a new class that implements the javax.servlet.Filter interface:

public class TrailingSlashRedirectFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
      throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String path = httpRequest.getRequestURI();

        if (path.endsWith("/")) {
            String newPath = path.substring(0, path.length() - 1);
            HttpServletRequest newRequest = new CustomHttpServletRequestWrapper(httpRequest, newPath);
            chain.doFilter(newRequest, response);
        } else {
            chain.doFilter(request, response);
        }
    }

    private static class CustomHttpServletRequestWrapper extends HttpServletRequestWrapper {

        private final String newPath;

        public CustomHttpServletRequestWrapper(HttpServletRequest request, String newPath) {
            super(request);
            this.newPath = newPath;
        }

        @Override
        public String getRequestURI() {
            return newPath;
        }

        @Override
        public StringBuffer getRequestURL() {
            StringBuffer url = new StringBuffer();
            url.append(getScheme()).append("://").append(getServerName()).append(":").append(getServerPort())
              .append(newPath);
            return url;
        }
    }
}

We implement the Filter interface in this custom filter and override the doFilter method. First, we cast the ServletRequest to an HttpServletRequest to access the request URI. Then, we check if the URI ends with a slash. If it does, we remove the trailing slash using a new CustomHttpServletRequestWrapper, a private static class extending HttpServletRequestWrapper. This class overrides the getRequestURI and getRequestURL methods to return the modified URI and URL.

Lastly, to apply our custom filter to all endpoints, we can register it using a FilterRegistrationBean with a URL pattern of “/*”. Here is an example:

@Configuration
public class WebConfig {

    @Bean
    public Filter trailingSlashRedirectFilter() {
        return new TrailingSlashRedirectFilter();
    }

    @Bean
    public FilterRegistrationBean<Filter> trailingSlashFilter() {
        FilterRegistrationBean<Filter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(trailingSlashRedirectFilter());
        registrationBean.addUrlPatterns("/*");
        return registrationBean;
    }
}

Note that applying the filter to all endpoints may have performance implications and can cause unexpected behavior if we have custom endpoints that do not follow standard RESTful URL patterns. In general, applying filters only to the endpoints that require them is recommended.

Finally, let’s look at a couple of tests with the filter in place:

private static final String BASEURL = "/some";

@Autowired
MockMvc mvc;

@Test
public void testGreeting() throws Exception {
    mvc.perform(get(BASEURL + "/greeting").accept(MediaType.APPLICATION_JSON_VALUE))
      .andExpect(status().isOk())
      .andExpect(content().string("Hello"));
}

@Test
public void testGreetingTrailingSlashWithFilter() throws Exception {
    mvc.perform(get(BASEURL + "/greeting/").accept(MediaType.APPLICATION_JSON_VALUE))
      .andExpect(status().isOk())
      .andExpect(content().string("Hello"));
}

6. Configure Redirect Using a Custom WebFilter

For reactive endpoints, we can create a custom class that implements the WebFilter interface and override its filter method:

public class TrailingSlashRedirectFilterReactive implements WebFilter {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getPath().value();

        if (path.endsWith("/")) {
            String newPath = path.substring(0, path.length() - 1);
            ServerHttpRequest newRequest = request.mutate().path(newPath).build();
            return chain.filter(exchange.mutate().request(newRequest).build());
        }

        return chain.filter(exchange);
    }
}

First, we extract the request from the ServerWebExchange parameter. We use the getPath method to get the incoming request’s path and check if it ends with a slash. If it does, we remove the trailing slash and create a new ServerHttpRequest using the mutate method. We then pass the modified exchange object to the filter method on the WebFilterChain parameter. We call the filter method with the original exchange object if the path doesn’t end with a slash.

To register the WebFilter, we annotate it with @Component, and Spring Boot will automatically register it with the appropriate WebFilterChain.

To specify the paths for which the custom TrailingSlashRedirectFilterReactive should apply, we can use the @WebFilter annotation and set the urlPatterns attribute to a list of URL patterns.

Finally, let’s look at a couple of tests with the filter in place:

private static final String BASEURL = "/some/reactive";

@Autowired
private WebTestClient webClient;

@Test
public void testGreeting() {
    webClient.get().uri( BASEURL + "/greeting")
      .exchange()
      .expectStatus().isOk()
      .expectBody().consumeWith(result -> {
          String responseBody = new String(result.getResponseBody());
          assertTrue(responseBody.contains("Hello reactive"));
      });
}
   
@Test
public void testGreetingTrailingSlashWithFilter() {
    webClient.get().uri(BASEURL +  "/greeting/")
      .exchange()
      .expectStatus().isOk()
      .expectBody().consumeWith(result -> {
          String responseBody = new String(result.getResponseBody());
          assertTrue(responseBody.contains("Hello reactive"));
      });
}

7. Configure Redirect Through a Proxy

Redirecting requests from URLs that end with a trailing slash to URLs without a trailing slash is a common task when configuring web servers. This can help ensure that all URLs on your site have a consistent structure and improve search engine optimization (SEO).

Moreover, most web servers have built-in support for URL redirection. This support can be used to redirect requests with trailing slashes to URLs without trailing slashes.

Let’s explore how to configure redirects using a proxy for two popular web servers: Apache and Nginx.

7.1. Nginx

location / {
    if ($request_uri ~ ^(.+)/$) {
        return 301 $1;
    }
    
    proxy_pass http://localhost:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

In this example, we add the if block to the root location block. Moreover, the if block checks if the request URI ends with a trailing slash. If the URI ends with a trailing slash, it redirects the request to the same URI without the trailing slash using a 301 redirect. The $request_uri is a predefined Nginx variable that contains the original request URI as received from the client, including the query string (if any). The regular expression “^(.+)/$” has a capture group “(.+)”, which captures any sequence of characters followed by a trailing slash. The $1 in the return directive refers to the first captured group, i.e., the matched URI without the trailing slash.

We then use the proxy_pass directive to specify the URL of the backend server that handles the request and use the proxy_set_header directive to set the necessary headers. Note that we need to replace the URL with the actual URL of the backend server.

7.2. Apache

RewriteEngine On
RewriteRule ^(.+)/$ $1 [L,R=301]

ProxyPass / http://localhost:8080/
ProxyPassReverse / http://localhost:8080/

In this example, we use a RewriteRule with the regular expression we used in the Nginx configuration as well. When the RewriteRule is executed, it replaces the matched URL with a trailing slash with the value captured by the first group (the URL without the trailing slash) and performs a 301 redirect.

We then use the ProxyPass and ProxyPassReverse directives to specify the URL of the backend server that handles the request. We can add this configuration within the  block, which applies it to the entire site.

8. Conclusion

In this article, we discussed Spring Boot 3’s deprecation of the trailing slash matching configuration option, which significantly impacts URL mapping in the framework, requiring some effort but providing a stable and consistent foundation for applications. By understanding this change and updating our applications accordingly, we can ensure a seamless and consistent user experience.

As always, the code for these examples is available over on GitHub.