Skip to content

Commit

Permalink
Provide a way to set the root context path (#4802)
Browse files Browse the repository at this point in the history
Motivation:

Described in #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 <[email protected]>
Co-authored-by: Ikhun Um <[email protected]>
  • Loading branch information
3 people authored Oct 13, 2023
1 parent 2bda05b commit d9b6142
Show file tree
Hide file tree
Showing 6 changed files with 275 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpService> delegates = ctx.attr(DECORATOR_KEY);
Expand Down
29 changes: 29 additions & 0 deletions core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
*
* <pre>{@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();
* }
* </pre>
*
* <p>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}.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,8 @@ ServiceConfig build(ServiceNaming defaultServiceNaming,
HttpHeaders virtualHostDefaultHeaders,
Function<? super RoutingContext, ? extends RequestId> defaultRequestIdGenerator,
ServiceErrorHandler defaultServiceErrorHandler,
@Nullable UnhandledExceptionsReporter unhandledExceptionsReporter) {
@Nullable UnhandledExceptionsReporter unhandledExceptionsReporter,
String baseContextPath) {
ServiceErrorHandler errorHandler =
serviceErrorHandler != null ? serviceErrorHandler.orElse(defaultServiceErrorHandler)
: defaultServiceErrorHandler;
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<SslContextBuilder> sslContextBuilderSupplier;
@Nullable
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -702,7 +714,7 @@ VirtualHostBuilder addRouteDecoratingService(RouteDecoratingService routeDecorat

@Nullable
private Function<? super HttpService, ? extends HttpService> getRouteDecoratingService(
@Nullable VirtualHostBuilder defaultVirtualHostBuilder) {
@Nullable VirtualHostBuilder defaultVirtualHostBuilder, String baseContextPath) {
final List<RouteDecoratingService> routeDecoratingServices;
if (defaultVirtualHostBuilder != null) {
routeDecoratingServices = ImmutableList.<RouteDecoratingService>builder()
Expand All @@ -714,8 +726,10 @@ VirtualHostBuilder addRouteDecoratingService(RouteDecoratingService routeDecorat
}

if (!routeDecoratingServices.isEmpty()) {
return RouteDecoratingService.newDecorator(
Routers.ofRouteDecoratingService(routeDecoratingServices));
final List<RouteDecoratingService> prefixed = routeDecoratingServices.stream()
.map(service -> service.withRoutePrefix(baseContextPath))
.collect(toImmutableList());
return RouteDecoratingService.newDecorator(Routers.ofRouteDecoratingService(prefixed));
} else {
return null;
}
Expand Down Expand Up @@ -1312,7 +1326,7 @@ VirtualHost build(VirtualHostBuilder template, DependencyInjector dependencyInje
successFunction, requestAutoAbortDelayMillis,
multipartUploadsLocation, defaultHeaders,
requestIdGenerator, defaultErrorHandler,
unhandledExceptionsReporter);
unhandledExceptionsReporter, baseContextPath);
}).collect(toImmutableList());

final ServiceConfig fallbackServiceConfig =
Expand All @@ -1321,7 +1335,7 @@ VirtualHost build(VirtualHostBuilder template, DependencyInjector dependencyInje
accessLogWriter, blockingTaskExecutor, successFunction,
requestAutoAbortDelayMillis, multipartUploadsLocation,
defaultHeaders, requestIdGenerator,
defaultErrorHandler, unhandledExceptionsReporter);
defaultErrorHandler, unhandledExceptionsReporter, "/");

final ImmutableList.Builder<ShutdownSupport> builder = ImmutableList.builder();
builder.addAll(shutdownSupports);
Expand All @@ -1336,7 +1350,7 @@ VirtualHost build(VirtualHostBuilder template, DependencyInjector dependencyInje
builder.build(), requestIdGenerator);

final Function<? super HttpService, ? extends HttpService> decorator =
getRouteDecoratingService(template);
getRouteDecoratingService(template, baseContextPath);
return decorator != null ? virtualHost.decorate(decorator) : virtualHost;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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
Expand All @@ -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);
}
}

0 comments on commit d9b6142

Please sign in to comment.