1
votes

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.

2
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
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

2 Answers

1
votes

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();
            }
        };
    }

}
0
votes

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"})