From d9b6142ecc673e1690a963d7ce8920e6272d98da Mon Sep 17 00:00:00 2001 From: bkkanq <69157416+bkkanq@users.noreply.github.com> Date: Fri, 13 Oct 2023 18:23:06 +0900 Subject: [PATCH] Provide a way to set the root context path (#4802) Motivation: Described in https://github.com/line/armeria/issues/3591 Modifications: - Add `ContextServiceBuilder` to bind a root context path to `VirtualHost` Result: - User can set the root context path - TODO: Support to set `contextPath` in Armeria Spring integration - Closes #3591 Co-authored-by: jrhee17 Co-authored-by: Ikhun Um --- .../server/RouteDecoratingService.java | 12 ++ .../armeria/server/ServerBuilder.java | 29 +++ .../armeria/server/ServiceConfigBuilder.java | 7 +- .../armeria/server/VirtualHostBuilder.java | 26 ++- .../armeria/server/BaseContextPathTest.java | 176 ++++++++++++++++++ .../server/RouteDecoratingServiceTest.java | 33 ++++ 6 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 core/src/test/java/com/linecorp/armeria/server/BaseContextPathTest.java diff --git a/core/src/main/java/com/linecorp/armeria/internal/server/RouteDecoratingService.java b/core/src/main/java/com/linecorp/armeria/internal/server/RouteDecoratingService.java index c45618b2955..baa886b2eb5 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/server/RouteDecoratingService.java +++ b/core/src/main/java/com/linecorp/armeria/internal/server/RouteDecoratingService.java @@ -91,6 +91,18 @@ public RouteDecoratingService(Route route, String contextPath, decorator = requireNonNull(decoratorFunction, "decoratorFunction").apply(this); } + private RouteDecoratingService(Route route, HttpService decorator) { + this.route = requireNonNull(route, "route"); + this.decorator = requireNonNull(decorator, "decorator"); + } + + /** + * Adds the specified {@code prefix} to this {@code decorator}. + */ + public RouteDecoratingService withRoutePrefix(String prefix) { + return new RouteDecoratingService(route.withPrefix(prefix), decorator); + } + @Override public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception { final Queue delegates = ctx.attr(DECORATOR_KEY); diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java b/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java index 0cd549e2042..afb338fee0c 100644 --- a/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java @@ -1449,6 +1449,35 @@ public ServerBuilder defaultHostname(String defaultHostname) { return this; } + /** + * Sets the base context path for this {@link ServerBuilder}. Services and decorators added to this + * {@link ServerBuilder} will be prefixed by the specified {@code baseContextPath}. If a service is bound + * to a scoped {@link #contextPath(String...)}, the {@code baseContextPath} will be prepended to the + * {@code contextPath}. + * + *
{@code
+     * Server
+     *   .builder()
+     *   .baseContextPath("/api")
+     *   // The following service will be served at '/api/v1/items'.
+     *   .service("/v1/items", itemService)
+     *   .contextPath("/v2")
+     *   // The following service will be served at '/api/v2/users'.
+     *   .service("/users", usersService)
+     *   .and() // end of the "/v2" contextPath
+     *   .build();
+     * }
+     * 
+ * + *

Note that the {@code baseContextPath} won't be applied to {@link VirtualHost}s + * added to this {@link Server}. To configure the context path for individual + * {@link VirtualHost}s, use {@link VirtualHostBuilder#baseContextPath(String)} instead. + */ + public ServerBuilder baseContextPath(String baseContextPath) { + defaultVirtualHostBuilder.baseContextPath(baseContextPath); + return this; + } + /** * Configures the default {@link VirtualHost} with the {@code customizer}. */ diff --git a/core/src/main/java/com/linecorp/armeria/server/ServiceConfigBuilder.java b/core/src/main/java/com/linecorp/armeria/server/ServiceConfigBuilder.java index b6dc312e78e..f158f4a39bd 100644 --- a/core/src/main/java/com/linecorp/armeria/server/ServiceConfigBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/server/ServiceConfigBuilder.java @@ -296,7 +296,8 @@ ServiceConfig build(ServiceNaming defaultServiceNaming, HttpHeaders virtualHostDefaultHeaders, Function defaultRequestIdGenerator, ServiceErrorHandler defaultServiceErrorHandler, - @Nullable UnhandledExceptionsReporter unhandledExceptionsReporter) { + @Nullable UnhandledExceptionsReporter unhandledExceptionsReporter, + String baseContextPath) { ServiceErrorHandler errorHandler = serviceErrorHandler != null ? serviceErrorHandler.orElse(defaultServiceErrorHandler) : defaultServiceErrorHandler; @@ -334,8 +335,10 @@ ServiceConfig build(ServiceNaming defaultServiceNaming, requestAutoAbortDelayMillis = WebSocketUtil.DEFAULT_REQUEST_AUTO_ABORT_DELAY_MILLIS; } + final Route routeWithBaseContextPath = route.withPrefix(baseContextPath); return new ServiceConfig( - route, mappedRoute == null ? route : mappedRoute, + routeWithBaseContextPath, + mappedRoute == null ? routeWithBaseContextPath : mappedRoute, service, defaultLogName, defaultServiceName, this.defaultServiceNaming != null ? this.defaultServiceNaming : defaultServiceNaming, requestTimeoutMillis, diff --git a/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java b/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java index 63967ef606b..2ee9acab220 100644 --- a/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java @@ -82,6 +82,7 @@ import com.linecorp.armeria.common.util.SystemInfo; import com.linecorp.armeria.internal.common.util.SelfSignedCertificate; import com.linecorp.armeria.internal.server.RouteDecoratingService; +import com.linecorp.armeria.internal.server.RouteUtil; import com.linecorp.armeria.internal.server.annotation.AnnotatedServiceExtensions; import com.linecorp.armeria.server.annotation.ExceptionHandlerFunction; import com.linecorp.armeria.server.annotation.RequestConverterFunction; @@ -121,6 +122,7 @@ public final class VirtualHostBuilder implements TlsSetters, ServiceConfigsBuild @Nullable private String hostnamePattern; private int port = -1; + private String baseContextPath = "/"; @Nullable private Supplier sslContextBuilderSupplier; @Nullable @@ -207,6 +209,16 @@ public VirtualHostBuilder defaultHostname(String defaultHostname) { return this; } + /** + * Sets the base context path for this {@link VirtualHost}. + * Services and decorators added to this {@link VirtualHost} will + * be prefixed by the specified {@code baseContextPath}. + */ + public VirtualHostBuilder baseContextPath(String baseContextPath) { + this.baseContextPath = RouteUtil.ensureAbsolutePath(baseContextPath, "baseContextPath"); + return this; + } + /** * Sets the hostname pattern of this {@link VirtualHost}. * If the hostname pattern contains a port number such {@code *.example.com:8080}, the returned virtual host @@ -702,7 +714,7 @@ VirtualHostBuilder addRouteDecoratingService(RouteDecoratingService routeDecorat @Nullable private Function getRouteDecoratingService( - @Nullable VirtualHostBuilder defaultVirtualHostBuilder) { + @Nullable VirtualHostBuilder defaultVirtualHostBuilder, String baseContextPath) { final List routeDecoratingServices; if (defaultVirtualHostBuilder != null) { routeDecoratingServices = ImmutableList.builder() @@ -714,8 +726,10 @@ VirtualHostBuilder addRouteDecoratingService(RouteDecoratingService routeDecorat } if (!routeDecoratingServices.isEmpty()) { - return RouteDecoratingService.newDecorator( - Routers.ofRouteDecoratingService(routeDecoratingServices)); + final List prefixed = routeDecoratingServices.stream() + .map(service -> service.withRoutePrefix(baseContextPath)) + .collect(toImmutableList()); + return RouteDecoratingService.newDecorator(Routers.ofRouteDecoratingService(prefixed)); } else { return null; } @@ -1312,7 +1326,7 @@ VirtualHost build(VirtualHostBuilder template, DependencyInjector dependencyInje successFunction, requestAutoAbortDelayMillis, multipartUploadsLocation, defaultHeaders, requestIdGenerator, defaultErrorHandler, - unhandledExceptionsReporter); + unhandledExceptionsReporter, baseContextPath); }).collect(toImmutableList()); final ServiceConfig fallbackServiceConfig = @@ -1321,7 +1335,7 @@ VirtualHost build(VirtualHostBuilder template, DependencyInjector dependencyInje accessLogWriter, blockingTaskExecutor, successFunction, requestAutoAbortDelayMillis, multipartUploadsLocation, defaultHeaders, requestIdGenerator, - defaultErrorHandler, unhandledExceptionsReporter); + defaultErrorHandler, unhandledExceptionsReporter, "/"); final ImmutableList.Builder builder = ImmutableList.builder(); builder.addAll(shutdownSupports); @@ -1336,7 +1350,7 @@ VirtualHost build(VirtualHostBuilder template, DependencyInjector dependencyInje builder.build(), requestIdGenerator); final Function decorator = - getRouteDecoratingService(template); + getRouteDecoratingService(template, baseContextPath); return decorator != null ? virtualHost.decorate(decorator) : virtualHost; } diff --git a/core/src/test/java/com/linecorp/armeria/server/BaseContextPathTest.java b/core/src/test/java/com/linecorp/armeria/server/BaseContextPathTest.java new file mode 100644 index 00000000000..1ad1d903ff2 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/server/BaseContextPathTest.java @@ -0,0 +1,176 @@ +/* + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.ServerSocket; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.client.ClientFactory; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.internal.testing.FlakyTest; +import com.linecorp.armeria.internal.testing.MockAddressResolverGroup; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +@FlakyTest +class BaseContextPathTest { + private static int normalServerPort; + private static int fooHostPort; + private static int barHostPort; + + private static ClientFactory clientFactory; + + @RegisterExtension + static ServerExtension serverWithPortMapping = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) throws Exception { + + try (ServerSocket ss = new ServerSocket(0)) { + normalServerPort = ss.getLocalPort(); + } + try (ServerSocket ss = new ServerSocket(0)) { + barHostPort = ss.getLocalPort(); + } + try (ServerSocket ss = new ServerSocket(0)) { + fooHostPort = ss.getLocalPort(); + } + + sb.http(normalServerPort) + .http(fooHostPort) + .http(barHostPort) + .service("/foo", (ctx, req) -> HttpResponse.of(HttpStatus.OK)) + .service("/hello", (ctx, req) -> HttpResponse.of(HttpStatus.OK)) + .service("/api/v1/bar", (ctx, req) -> HttpResponse.of(HttpStatus.ACCEPTED)) + .decorator("/deco", ((delegate, ctx, req) -> HttpResponse.of(HttpStatus.OK))) + .contextPath("/admin") + .service("/foo", (ctx, req) -> HttpResponse.of(ctx.path())) + .and() + .baseContextPath("/home") + // 2 + .virtualHost("*.foo.com:" + fooHostPort) + .baseContextPath("/api/v1") + .decorator("/deco", ((delegate, ctx, req) -> HttpResponse.of(HttpStatus.OK))) + .service("/good", (ctx, req) -> HttpResponse.of(HttpStatus.OK)) + .service("/bar", (ctx, req) -> HttpResponse.of(HttpStatus.OK)) + .contextPath("/admin") + .service("/foo", (ctx, req) -> HttpResponse.of(ctx.path())) + .and() + .and() + // 3 + .virtualHost("*.bar.com:" + barHostPort) + .baseContextPath("/api/v2") + .service("/world", (ctx, req) -> HttpResponse.of(HttpStatus.OK)) + .service("/bad", (ctx, req) -> HttpResponse.of(HttpStatus.OK)) + .and() + // 4 + .virtualHost("*.hostmap.com") + .baseContextPath("/api/v3") + .service("/me", (ctx, req) -> HttpResponse.of(HttpStatus.OK)) + .service("/you", (ctx, req) -> HttpResponse.of(HttpStatus.OK)) + .and() + .build(); + } + }; + + @BeforeAll + static void init() { + clientFactory = ClientFactory.builder() + .addressResolverGroupFactory(group -> MockAddressResolverGroup.localhost()) + .build(); + } + + @AfterAll + static void destroy() { + clientFactory.closeAsync(); + } + + @Test + void defaultVirtualHost() { + final WebClient defaultClient = WebClient.of("http://127.0.0.1:" + normalServerPort); + assertThat(defaultClient.get("/home/foo").aggregate().join().status()) + .isEqualTo(HttpStatus.OK); + assertThat(defaultClient.get("/home/hello").aggregate().join().status()) + .isEqualTo(HttpStatus.OK); + assertThat(defaultClient.get("/home/api/v1/bar").aggregate().join().status()) + .isEqualTo(HttpStatus.ACCEPTED); + assertThat(defaultClient.get("/home/deco").aggregate().join().status()) + .isEqualTo(HttpStatus.OK); + } + + @Test + void portBasedVirtualHost() { + final WebClient fooClient = WebClient.builder("http://foo.com:" + fooHostPort) + .factory(clientFactory) + .build(); + + assertThat(fooClient.get("/api/v1/bar").aggregate().join().status()) + .isEqualTo(HttpStatus.OK); + assertThat(fooClient.get("/api/v1/good").aggregate().join().status()) + .isEqualTo(HttpStatus.OK); + assertThat(fooClient.get("/api/v2/bad").aggregate().join().status()) + .isEqualTo(HttpStatus.NOT_FOUND); + + final WebClient barClient = WebClient.builder("http://bar.com:" + barHostPort) + .factory(clientFactory) + .build(); + + assertThat(barClient.get("/api/v2/world").aggregate().join().status()) + .isEqualTo(HttpStatus.OK); + assertThat(barClient.get("/api/v2/bad").aggregate().join().status()) + .isEqualTo(HttpStatus.OK); + assertThat(barClient.get("/api/v2/deco").aggregate().join().status()) + .isEqualTo(HttpStatus.OK); + assertThat(barClient.get("/api/v1/good").aggregate().join().status()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void pathBasedVirtualHost() { + final WebClient testClient = WebClient.builder("http://hostmap.com" + ":" + normalServerPort) + .factory(clientFactory) + .build(); + assertThat(testClient.get("/api/v3/me").aggregate().join().status()) + .isEqualTo(HttpStatus.OK); + assertThat(testClient.get("/api/v3/you").aggregate().join().status()) + .isEqualTo(HttpStatus.OK); + } + + @Test + void baseContextPathWithScopedContextPath() { + final BlockingWebClient client = BlockingWebClient.of( + "http://127.0.0.1:" + normalServerPort); + assertThat(client.get("/home/admin/foo") + .contentUtf8()).isEqualTo("/home/admin/foo"); + assertThat(client.get("/home/admin/foo") + .contentUtf8()).isEqualTo("/home/admin/foo"); + + final BlockingWebClient fooClient = WebClient.builder("http://foo.com:" + fooHostPort) + .factory(clientFactory) + .build() + .blocking(); + assertThat(fooClient.get("/api/v1/admin/foo").contentUtf8()) + .isEqualTo("/api/v1/admin/foo"); + } +} diff --git a/core/src/test/java/com/linecorp/armeria/server/RouteDecoratingServiceTest.java b/core/src/test/java/com/linecorp/armeria/server/RouteDecoratingServiceTest.java index 0d9f765d38e..559604dd866 100644 --- a/core/src/test/java/com/linecorp/armeria/server/RouteDecoratingServiceTest.java +++ b/core/src/test/java/com/linecorp/armeria/server/RouteDecoratingServiceTest.java @@ -41,6 +41,19 @@ protected void configure(ServerBuilder sb) throws Exception { } }; + @RegisterExtension + static final ServerExtension baseContextPathAppliedServer = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) throws Exception { + sb.service("/", (ctx, req) -> HttpResponse.of("Hello, world!")); + sb.routeDecorator() + .methods(HttpMethod.TRACE) + .pathPrefix("/") + .build((delegate, ctx, req) -> HttpResponse.of(HttpStatus.INTERNAL_SERVER_ERROR)); + sb.baseContextPath("/api"); + } + }; + @Test void routeDecorator() throws Exception { final WebClient webClient = WebClient.of(server.httpUri()); @@ -50,6 +63,16 @@ void routeDecorator() throws Exception { final HttpResponse response2 = webClient.execute(HttpRequest.of(HttpMethod.TRACE, "/")); assertThat(response2.aggregate().get().status()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + + final WebClient baseContextPathAppliedWebClient = WebClient.of(baseContextPathAppliedServer.httpUri()); + // This GET request doesn't go through the decorator. + final HttpResponse response3 = baseContextPathAppliedWebClient + .execute(HttpRequest.of(HttpMethod.GET, "/api/")); + assertThat(response3.aggregate().get().status()).isEqualTo(HttpStatus.OK); + + final HttpResponse response4 = baseContextPathAppliedWebClient + .execute(HttpRequest.of(HttpMethod.TRACE, "/api/")); + assertThat(response4.aggregate().get().status()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); } @Test @@ -61,5 +84,15 @@ void routeDecorator_notExistApi() throws Exception { final HttpResponse response2 = webClient.execute(HttpRequest.of(HttpMethod.TRACE, "/not_exist")); assertThat(response2.aggregate().get().status()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + + final WebClient baseContextPathAppliedWebClient = WebClient.of(server.httpUri()); + // This GET request doesn't go through the decorator. + final HttpResponse response3 = baseContextPathAppliedWebClient + .execute(HttpRequest.of(HttpMethod.GET, "/api/not_exist")); + assertThat(response3.aggregate().get().status()).isEqualTo(HttpStatus.NOT_FOUND); + + final HttpResponse response4 = baseContextPathAppliedWebClient + .execute(HttpRequest.of(HttpMethod.TRACE, "/api/not_exist")); + assertThat(response4.aggregate().get().status()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); } }