spring-cloud / spring-cloud-gateway

An API Gateway built on Spring Framework and Spring Boot providing routing and more.
http://cloud.spring.io
Apache License 2.0
4.5k stars 3.31k forks source link

Webflux base path does not work with Path predicates #1759

Open anjuna opened 4 years ago

anjuna commented 4 years ago

Describe the bug

When setting a spring.webflux.base-path property all routes using Path predicates return 404 results

Sample

application.name: demo
server.port: 8080

spring.webflux.base-path: /myapp

spring.cloud.gateway:
  routes:
    - id: google-route
      uri: https://www.google.com/
      predicates:
        - Path=/api/**
      filters:
        - StripPrefix=1

Expected: Visit localhost:8080/myapp/api to see the google homepage (albeit stripped down). Actual: 404

After digging a bit, I think this is because the PathRoutePredicateFactory uses the URI getRawPath.

Instead it could use a ServerHttpRequest's RequestPath then call pathWithinApplication, which is already set up correctly via the ContextPathCompositeHandler.

It is this ContextPathCompositeHandler which rejects the request earlier if you visit just localhost:8080/api, so the base-path property seems to be broken either way.

Is this change sensible? Or is there a separate feature which allows this functionality?

Thanks for your time.

spencergibb commented 4 years ago

spring.webflux.base-path is new so it doesn't surprise me that there are problems. Does Path=/myapp/api/** work?

I'll leave the solution to later since I haven't looked at it yet.

anjuna commented 4 years ago

Using Path=/myapp/api/** does work although the StripPrefix filter is then applied to the myapp segment.

So for example, hitting http://localhost:8080/myapp/api/maps returns a 404 from google saying "/api/maps was not found"

spencergibb commented 4 years ago

sure, you'd need to make it StripPrefix=2

anjuna commented 4 years ago

So it works with:

spring.webflux.base-path: /myapp

spring.cloud.gateway:
  routes:
    - id: google-route
      uri: https://www.google.com/
      predicates:
        - Path=${spring.webflux.base-path}/api/**
      filters:
        - StripPrefix=2

But then if base-path: is set, ie. the normal case with no overriding base path, then only StripPrefix=1 works.

I've also got related problems with authentication endpoints set on ServerHttpSecurity, but that might be covered by issue 21679 on spring-boot.

spencergibb commented 4 years ago

After discussing it, we don't want to add support for this in the various places it would need to go (because of our previous experience with zuul). Instead, we will add some documentation noting things mentioned in this issue.

anjuna commented 4 years ago

This was a hard requirement for us so I implemented a workaround:

http
    .addFilterAt(new StripsProxyPathFilter, SecurityWebFiltersOrder.FIRST)
    //etc

Where the StripsProxyPathFilter just takes the substring of the url without the basePath.

I'm interested why the team don't want to re-implement this - what's the past experience of zuul that influenced this decision?

Thanks.

rickgong commented 4 years ago

This is my workaround , and it work with SCG and webflux. (Sorry i don't know how to fix the code layout.....).

    @Bean
    @Primary
    public HttpHandler httpHandler() {
        HttpHandler httpHandler = WebHttpHandlerBuilder.applicationContext(this.applicationContext).build();
        return new ContextPathHttpHandler(httpHandler, "/yourContextPathHere");
    }

class ContextPathHttpHandler implements HttpHandler {
    private final HttpHandler delegate;
    private final String contextPath;

    public ContextPathHttpHandler(HttpHandler delegate, String contextPath) {
        this.delegate = delegate;
        this.contextPath = contextPath;
    }

    @Override
    public Mono<Void> handle(ServerHttpRequest request, ServerHttpResponse response) {
        return delegate.handle(withoutContextPath(request), withoutCache(response));
    }

    private ServerHttpRequest withoutContextPath(ServerHttpRequest request) {
        String path = request.getPath().value();
        if (path.startsWith(contextPath)) {
            String pathWithApplication = path.substring(contextPath.length());
            if(!StringUtils.hasText(pathWithApplication)){
                pathWithApplication="/";
            }
            return request.mutate().path(pathWithApplication).build();
        }
        return request;
    }

    private ServerHttpResponse withoutCache(ServerHttpResponse response) {
        response.getHeaders().set("cache-control", "no-store");
        return response;
    }
}
sumitsarkar commented 3 years ago

After some experiments and diving into how the filters work, I seem to have gotten around this issue. In case someone is still out there trying to see how to use spring.webflux.base-path with Spring Cloud Gateway, here's what I tried to make it work.

The code is in Kotlin.


@Component
class StripContextAndPrefixGatewayFilterFactory :
    AbstractGatewayFilterFactory<StripContextAndPrefixGatewayFilterFactory.Config>(Config::class.java) {
    override fun apply(config: Config): GatewayFilter {
        return GatewayFilter { exchange: ServerWebExchange, chain: GatewayFilterChain ->
            val builder: ServerHttpRequest.Builder = exchange.request.mutate()

            val request = exchange.request
            ServerWebExchangeUtils.addOriginalRequestUrl(exchange, request.uri)

            val path = request.uri.rawPath

            val pathWithoutBase = path.removePrefix(config.basePath ?: "")
            val suffixPath =
                "/" + Arrays.stream(StringUtils.tokenizeToStringArray(pathWithoutBase, "/")).skip(config.parts.toLong())
                    .collect(Collectors.joining("/"))

            chain.filter(
                exchange.mutate().request(builder.contextPath(suffixPath).path(suffixPath).build()).build()
            )
        }
    }

    data class Config(
        val basePath: String? = null,
        val parts: Int = 0
    )
}

This class is a modified version of StripPrefix filter. The value of base in Config is ${spring.webflux.basePath}. The trick is to make sure the path contains the contextPath of the request.

builder.contextPath(suffixPath).path(suffixPath).build() did it for me.

skerdudou commented 3 years ago

I had similar issue and quite a similar solution as well, I created a filter to automatically strip the basePath in outgoing requests. This filter don't need any parameter.

import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;

/**
 * @author skerdudou Remove basePath from outgoing request
 */
@Component
public class StripBasePathGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {

    @Value("${spring.webflux.base-path}")
    private String basePath;

    @Override
    public GatewayFilter apply(Object config) {
        return (exchange, chain) -> {
            ServerHttpRequest req = exchange.getRequest();
            String path = req.getURI().getRawPath();
            String newPath = path.replaceFirst(basePath, "");

            ServerHttpRequest request = req.mutate().path(newPath).contextPath(null).build();

            return chain.filter(exchange.mutate().request(request).build());
        };
    }
}
jigneshkhatri commented 3 years ago

The above solution provided by @skerdudou works just fine (though @sumitsarkar's solution also looks similar but I haven't checked on it). But for those wondering how to use (register) this custom filter, focus on the class name. The pattern in class name is like {filter name}GatewayFilterFactory. So here, for StripBasePathGatewayFilterFactory, filter name will be StripBasePath, and to use it in application.properties (or yaml) file, we need to define it like - spring.cloud.gateway.routes[0].filters=StripBasePath=1

qelan commented 2 years ago

Ok, this has literally had me stuck for a full week until I found this ticket. The "promised" documentation changes do not exist in the current Spring Cloud Gateway documentation, so if you add a spring.webflux.base-path things just plain stop working with no explanation of why.

Also, use of a base path is a requirement for our application, because we are adapting an existing Zuul API Gateway, with many people already having access to existing URLs. So, not using spring.webflux.base-path is not an option. And, trying to add the base path to all of the paths in the application also do not work, because things deeper like Actuator and Security start breaking if that is done.

Can someone please update the documentation to either include some of the workarounds in this ticket, or to give the "proper" way to have a base URL for the Spring Cloud Gateway?

Andross96 commented 1 year ago

Hello and thank you everyone for your work.

I want to mention that this problem still persists and we do not found an official way to handle this issue. When setting a spring.webflux.base-path property, we get a 404 on a simple (previously working) route:

routes:
- id: backend
  uri: http://localhost:9090
  predicates:
  - Path=/api/**
  filters:
  - StripPrefix=1

We are on Spring-boot 3.0.5 & Spring-cloud 2022.0.2 (should be the latests at the moment).

jyotibalodhi commented 1 year ago

Hi I am working with the spring.webflux.base-path property set to say "BasePath", and using a route such as

-id: service1 uri: https://xyz/ predicates: -Path=/BasePath/api/** filters: -StripBasePath=1

And for the StripBasePath implementation I worked around the implementation given by @skerdudou . It works fine when I try to hit the url https://xyz/api/** using the api server , https://abc/BasePath/api/** but whenever I am trying to make any post request using the api gateway it takes me to https://abc/api/** which won't work. Is there a way i can include the BasePath in the post requests but I do not want it when it hits the actual service.

haitaoss commented 1 year ago

I want to add parameters to determine whether to set the 'BasePath' prefix for '- Path=/api' based on the parameters. Can I contribute to the code. @spencergibb

nurlandadasev commented 9 months ago

Hi! We still need a solution to this problem! Is there any progress?

ilucatero commented 7 months ago

Indeed it's very weird that the base-path is pasted all over requests even on redirections, but not where we is needed.

The solution propose skerdudou works just fine for me.

My usecase adds routes programatly, and have a requirement to use contextpath, so here is part of solution I made using the proposed workaround :

        @Value("${spring.webflux.base-path}")
    private String contextPath;
..
        String appSubContext = "myApp"

        // NOTE : contextpath required on route or it wont match the incoming request otherwise
    routes.route(r -> r.path(contextPath + apiRute)
                  .filters(addContext(appSubContext))

...
 protected Function<GatewayFilterSpec, UriSpec> addContext(String context) {
        // NOTE: order of filters matters
        return f -> f
                .filter(stripBasePathGatewayFilterFactory.apply(this))
                .rewritePath("/(?<segment>.*)", context + "/${segment}");
    }

Hope this gives more insights of the problem.

marhali commented 3 months ago

Since I have also wasted almost a week of my time because of this issue, here is my solution:

GatwayFilter which fixes the webflux base-path issue

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

import lombok.Data;

import org.springframework.boot.autoconfigure.web.reactive.WebFluxProperties;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

@Component
public class StripBasePathGatewayFilterFactory extends AbstractGatewayFilterFactory<StripBasePathGatewayFilterFactory.Config> {

    private final String basePath;

    public StripBasePathGatewayFilterFactory(WebFluxProperties webFluxProperties) {
        super(Config.class);
        this.basePath = webFluxProperties.getBasePath();
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            ServerWebExchangeUtils.addOriginalRequestUrl(exchange, exchange.getRequest().getURI());

            String path = exchange.getRequest().getURI().getRawPath();
            String pathWithoutBase = path.replaceFirst(basePath != null ? basePath : "", "");

            String suffixPath = "/" + Arrays.stream(StringUtils.tokenizeToStringArray(pathWithoutBase, "/"))
                .skip(config.getParts())
                .collect(Collectors.joining("/"));

            ServerHttpRequest modifiedRequest = exchange.getRequest().mutate()
                .contextPath(suffixPath)
                .path(suffixPath)
                .build();

            return chain.filter(exchange.mutate().request(modifiedRequest).build());
        };
    }

    @Override
    public List<String> shortcutFieldOrder() {
        return List.of("parts");
    }

    @Data
    public static class Config {
        private int parts;
    }
}

This filter must be used on all gateway routes with the correct number of parts. As of my understanding parts is the number of the gateway route path filter predicate.

Example usage in application configuration yaml

spring:
  webflux:
    base-path: /api
  cloud:
    gateway:
      routes:
        - id: todo-service
          uri: lb://todo-service
          predicates:
            - Path=/api/todo/**
          filters:
            - StripBasePath=1 # <-- one part because of /todo
            - RewritePath=/todo/?(?<segment>.*), /$\{segment}
        - id: todo-service-with-multi-part
          uri: lb://todo-service
          predicates:
            - Path=/api/todo/service/**
          filters:
            - StripBasePath=2 # <-- two parts because of /todo/service
            - RewritePath=/todo/service/?(?<segment>.*), /$\{segment}

Hope this helps others who encounter the same problem :)

howardem commented 3 months ago

Thanks @marhali! It works!

predhme commented 1 week ago

This is a fairly frustrating difference between how the MVC and Flux variants behave.

I started with an MVC gateway as I had a preexisting security implementation that was MVC based. I however encountered an issue with the MVC implementation so decided to try the Flux gateway.

After some time reworking my security layer, I came to find this issue. I have attempted a number of the suggested workarounds, however, with an ingress, Springdoc Flux, and other variables, I am stuck in a place where things are not working as expected. Things that were working fine with the MVC gateway.

The most desirable change would be to get the Flux variant to use the base-path much like the MVC variant uses the servlet-context-path.

I unfortunately will have to either revert back to MVC version and "deal" with the aforementioned bug, or consider an alternative api-gateway.