2

Let's say I have the following controller with its parent class:

@RestController
public class BusinessController extends RootController {

    @GetMapping(value = "users", produces = {"application/json"})
    @ResponseBody
    public String users() {
        return "{ \"users\": [] }"
    }

    @GetMapping(value = "companies", produces = {"application/json"})
    @ResponseBody
    public String companies() {
        return "{ \"companies\": [] }"
    }

}

@RestController
@RequestMapping(path = "api")
public class RootController {

}

Data is retrieved by calling such URL's:

http://app.company.com/api/users
http://app.company.com/api/companies

Now let's say I want to rename the /api path to /rest but keep it "available" by returning a 301 HTTP status code alongside the new URI's

e.g. client request:

GET /api/users HTTP/1.1
Host: app.company.com

server request:

HTTP/1.1 301 Moved Permanently
Location: http://app.company.com/rest/users

So I plan to change from "api" to "rest" in my parent controller:

@RestController
@RequestMapping(path = "rest")
public class RootController {

}

then introduce a "legacy" controller:

@RestController
@RequestMapping(path = "api")
public class LegacyRootController {

}

but now how to make it "rewrite" the "legacy" URI's?

That's what I'm struggling with, I can't find anything Spring-related on the matter, whether on StackOverflow or elsewhere.

Also I have many controllers AND many methods-endpoints so I can not do this manually (i.e. by editing every @RequestMapping/@GetMapping annotations).

And project I'm working on is based on Spring Boot 2.1

Edit: I removed the /business path because actually inheritance doesn't work "by default" (see questions & answers like Spring MVC @RequestMapping Inheritance or Modifying @RequestMappings on startup ) - sorry for that.

maxxyme
  • 2,164
  • 5
  • 31
  • 48
  • I've provided an answer that takes a straightforward approach to this question. If you would like to have a blanket 301 & location header on every request to your application, please let me know if you have configurations extending MvcConfigurerAdapter – shinjw Sep 02 '20 at 15:18
  • actually as the project is based in Spring Boot, I only have a single config class annotated with `@SpringBootApplication` and a bunch of other ones with traditional Spring's `@Configuration` which provide a few `@Bean` or `@Component`; only 1 of them is the `public class SecurityConfig extends WebSecurityConfigurerAdapter` to customize Spring Security. – maxxyme Sep 03 '20 at 07:59

2 Answers2

2

I finally found a way to implement this, both as a javax.servlet.Filter AND a org.springframework.web.server.WebFilter implementation.

In fact, I introduced the Adapter pattern in order to transform both:

  • org.springframework.http.server.ServletServerHttpResponse (non-reactive) and
  • org.springframework.http.server.reactive.ServerHttpResponse (reactive)

because on the contrary of the Spring's HTTP requests' wrappers which share org.springframework.http.HttpRequest (letting me access both URI and HttpHeaders), the responses's wrappers do not share a common interface that does it, so I had to emulate one (here purposely named in a similar fashion, HttpResponse).

@Component
public class RestRedirectWebFilter implements Filter, WebFilter {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
            throws IOException, ServletException {
        ServletServerHttpRequest request = new ServletServerHttpRequest((HttpServletRequest) servletRequest);
        ServletServerHttpResponse response = new ServletServerHttpResponse((HttpServletResponse) servletResponse);

        if (actualFilter(request, adapt(response))) {
            chain.doFilter(servletRequest, servletResponse);
        }
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        if (actualFilter(exchange.getRequest(), adapt(exchange.getResponse()))) {
            return chain.filter(exchange);
        } else {
            return Mono.empty();
        }
    }

    /**
     * Actual filtering.
     * 
     * @param request
     * @param response
     * @return boolean flag specifying if filter chaining should continue.
     */
    private boolean actualFilter(HttpRequest request, HttpResponse response) {
        URI uri = request.getURI();
        String path = uri.getPath();
        if (path.startsWith("/api/")) {
            String newPath = path.replaceFirst("/api/", "/rest/");
            URI location = UriComponentsBuilder.fromUri(uri).replacePath(newPath).build().toUri();
            response.getHeaders().setLocation(location);
            response.setStatusCode(HttpStatus.MOVED_PERMANENTLY);
            response.flush();
            return false;
        }
        return true;
    }

    interface HttpResponse extends HttpMessage {

        void setStatusCode(HttpStatus status);

        void flush();

    }

    private HttpResponse adapt(ServletServerHttpResponse response) {
        return new HttpResponse() {
            public HttpHeaders getHeaders() {
                return response.getHeaders();
            }

            public void setStatusCode(HttpStatus status) {
                response.setStatusCode(status);
            }

            public void flush() {
                response.close();
            }
        };
    }

    private HttpResponse adapt(org.springframework.http.server.reactive.ServerHttpResponse response) {
        return new HttpResponse() {
            public HttpHeaders getHeaders() {
                return response.getHeaders();
            }

            public void setStatusCode(HttpStatus status) {
                response.setStatusCode(status);
            }

            public void flush() {
                response.setComplete();
            }
        };
    }

}
maxxyme
  • 2,164
  • 5
  • 31
  • 48
  • doFilter() and filter() don't get examined together per one request. – Brendan Kim May 23 '23 at 20:40
  • @BrendanKim sure... but each method implements a different interface (as I explained in my 1st sentence) - depending of your SB context (Reactive or Non-Reactive) – maxxyme May 25 '23 at 13:03
0

Since it looks like you want to preserve the 301 but also have it return a response, you do have the option to wire in your RootController into your LegacyRootController

That way you can provide reuse the logic you have in the RootController but return different response codes and serve different paths on your LegacyRootController

@RestController
@RequestMapping(path = "api")
public class LegacyRootController {
    
     private final RootController rootController;
     
     public LegacyRootController(RootController rootController) { 
         this.rootController = rootController;
     }

     @GetMapping(value = "users", produces = {"application/json"})
     @ResponseStatus(HttpStatus.MOVED_PERMANENTLY) // Respond 301
     @ResponseBody
     public String users() {
        return rootController.users(); // Use rootController to provide appropriate response. 
     }

     @GetMapping(value = "companies", produces = {"application/json"})
     @ResponseStatus(HttpStatus.MOVED_PERMANENTLY)
     @ResponseBody
     public String companies() {
         return rootController.companies();
     }
}

This will allow you to serve /api/users to serve up a response with a 301, while also allowing you to serve /rest/users with your standard response.

If you would like to add the Location headers, you can have your LegacyRootController return a ResponseEntity to provide the body, code and header values.

@GetMapping(value = "users", produces = {"application/json"})
public ResponseEntity<String> users() {
    HttpHeaders responseHeaders = new HttpHeaders();
    responseHeaders.setLocation("...");
    return new ResponseEntity<String>(rootController.users(), responseHeaders, HttpStatus.MOVED_PERMANENTLY);
}

If you want to serve multiple endpoints that does not serve different status codes, you can simply provide multiple paths

@RequestMapping(path = {"api", "rest"})

shinjw
  • 3,329
  • 3
  • 21
  • 42
  • 1
    If you would like to add a blanket 301 on all requests on your `api` path, you can register an interceptor to provide the response status and headers with a `HandlerInterceptor` – shinjw Sep 02 '20 at 15:11
  • thanks @shinjw for your answer; I can see your point, however there's a few difference to what I want/what can be done: 1. I do not necessarily want to serve the JSON response for the "old" /api endpoints, and 2. both RootController and LegacyRootController are empty: they do not hold the code, while e.g. BusinessController does. – maxxyme Sep 02 '20 at 15:25
  • Moreover, your suggestion using an Interceptor might be interesting, although I'll need to figure out how to check whether the URI are supposed to exist or not; I mean I wouldn't answer a `301` with `Location: /rest/nonsense` to request `GET /api/nonsense`... – maxxyme Sep 02 '20 at 15:27
  • 1
    https://stackoverflow.com/questions/31082981/spring-boot-adding-http-request-interceptors for some inspiration – shinjw Sep 02 '20 at 15:29
  • thanks, especially this answer or the following one: https://stackoverflow.com/a/55901453/666414 – maxxyme Sep 03 '20 at 08:05