Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide a way to set the root context path #4802

Merged
merged 14 commits into from Oct 13, 2023

Conversation

bkkanq
Copy link
Contributor

@bkkanq bkkanq commented Apr 4, 2023

Motivation:

Described in #3591

Modifications:

  • Add ContextServiceBuilder to bind a root context path to VirtualHost

Result:

@CLAassistant
Copy link

CLAassistant commented Apr 4, 2023

CLA assistant check
All committers have signed the CLA.

@bkkanq bkkanq force-pushed the 3591-provide-context-path branch from c3d9c46 to abe193b Compare April 4, 2023 03:03
@ikhoon
Copy link
Contributor

ikhoon commented Apr 4, 2023

Had an internal discussion about the API design of this PR. We were worried that the server and service builder become too complex by adding ContextServiceBuilder.

It should be nicer to reuse the existing API to build the services rather than reinventing the wheel. On second thought, I think, we can add a new concept of virtual hosting that serves all services under a certain path like ServerPath of Virtualhost in Apache HTTP server.

For example:

Server
  .builder()
  // return a path-based `VirtualHost` prefixed with `/api/v0`
  .virtualHostWithPath("/api/v0")
    .service(...)
    .decorator(...)
    .and()
  // return a path-based `VirtualHost` prefixed with `/api/v1`
  .virtualHostWithPath("/api/v1")
    .service(...)
    .decorator(...)
    .and()

To implement a path-based virtual host, you may want to check out the following points.

  • Add a new builder method to ServerBuilder.
    • If a predefined path exists, the same virtual host should be returned as port-based virtual hosting.
      public VirtualHostBuilder virtualHost(int port) {
      checkArgument(port >= 1 && port <= 65535, "port: %s (expected: 1-65535)", port);
      // Look for a virtual host that has already been made and reuse it.
      final Optional<VirtualHostBuilder> vhost =
      virtualHostBuilders.stream()
      .filter(v -> v.port() == port && v.defaultVirtualHost())
      .findFirst();
      if (vhost.isPresent()) {
      return vhost.get();
      }
      final VirtualHostBuilder virtualHostBuilder = new VirtualHostBuilder(this, port);
      virtualHostBuilders.add(virtualHostBuilder);
      return virtualHostBuilder;
      }
  • Fix buildDomainAndPortMapping to take the context path into account.
    • The return type of buildDomainAndPortMapping could be:
      - Int2ObjectMap<Mapping<String, VirtualHost>>
      + Int2ObjectMap<Mapping<String, List<VirtualHost>>>
      The size of List will be 1 if no path-based VirtualHost is configured. The type looks complex. If you have a good idea, feel free to change it.
      private static Int2ObjectMap<Mapping<String, VirtualHost>> buildDomainAndPortMapping(
      VirtualHost defaultVirtualHost, List<VirtualHost> virtualHosts) {
      final List<VirtualHost> portMappingVhosts = virtualHosts.stream()
      .filter(v -> v.port() > 0)
      .collect(toImmutableList());
      final Map<Integer, VirtualHost> portMappingDefaultVhosts =
      portMappingVhosts.stream()
      .filter(v -> v.hostnamePattern().startsWith("*:"))
      .collect(toImmutableMap(VirtualHost::port, Function.identity()));
      final Map<Integer, DomainMappingBuilder<VirtualHost>> mappingsBuilder = new HashMap<>();
      for (VirtualHost virtualHost : portMappingVhosts) {
      final int port = virtualHost.port();
      // The default virtual host should be either '*' or '*:<port>'.
      final VirtualHost defaultVhost =
      firstNonNull(portMappingDefaultVhosts.get(port), defaultVirtualHost);
      // Builds a 'DomainMappingBuilder' with 'defaultVhost' for the port if absent.
      final DomainMappingBuilder<VirtualHost> mappingBuilder =
      mappingsBuilder.computeIfAbsent(port, key -> new DomainMappingBuilder<>(defaultVhost));
      if (defaultVhost == virtualHost) {
      // The 'virtualHost' was added already as a default value when creating 'DomainMappingBuilder'.
      } else {
      mappingBuilder.add(virtualHost.hostnamePattern(), virtualHost);
      }
  • Use a request path to find a matched virtual host.
    We can add a path as the last parameter of ServerConfing.findVirtualHost().
    - public VirtualHost findVirtualHost(String hostname, int port) {
    + public VirtualHost findVirtualHost(String hostname, int port, String path) {
    @Override
    public VirtualHost findVirtualHost(String hostname, int port) {
    if (virtualHostAndPortMapping == null) {
    return defaultVirtualHost;
    }
    final Mapping<String, VirtualHost> virtualHostMapping = virtualHostAndPortMapping.get(port);
    if (virtualHostMapping != null) {
    final VirtualHost virtualHost = virtualHostMapping.map(hostname + ':' + port);
    // Exclude the default virtual host from port-based virtual hosts.
    if (virtualHost != defaultVirtualHost) {
    return virtualHost;
    }
    }
    // No port-based virtual host is configured. Look for named-based virtual host.
    final Mapping<String, VirtualHost> nameBasedMapping = virtualHostAndPortMapping.get(-1);
    assert nameBasedMapping != null;
    return nameBasedMapping.map(hostname);
    }

@bkkanq
Copy link
Contributor Author

bkkanq commented Apr 10, 2023

Thanks for the detailed explanation. let me update this draft PR soon. 🙇

@jrhee17 jrhee17 added this to the 1.25.0 milestone Jul 6, 2023
@bkkanq bkkanq force-pushed the 3591-provide-context-path branch 2 times, most recently from db59dcf to 9fb9f74 Compare July 13, 2023 14:56
Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some minor comments, but I think the overall direction looks good and the PR should be almost done soon.

I think if this is handled quickly, hopefully it can be released in this iteration 🤞

@@ -77,6 +77,8 @@ public final class VirtualHost {
private final String defaultHostname;
private final String hostnamePattern;
private final int port;
private final String contextPath;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to add contextPath to VirtualHost for the new direction.
What do you think of reverting this file?

@@ -181,6 +184,12 @@ static String normalizeDefaultHostname(String defaultHostname) {
return Ascii.toLowerCase(defaultHostname);
}

static String normalizeContextPath(String contextPath) {
requireNonNull(contextPath, "contextPath");
// TODO Implement this
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can just do something like the following.

RouteUtil.ensureAbsolutePath(contextPath, "contextPath");

We probably don't even need to define a dedicated method for this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aha, thank you. let me address it.

@@ -478,7 +501,8 @@ public VirtualHostBuilder serviceUnder(String pathPrefix, HttpService service) {
* @throws IllegalArgumentException if the specified path pattern is invalid
*/
public VirtualHostBuilder service(String pathPattern, HttpService service) {
service(Route.builder().path(pathPattern).build(), service);
final String prefixed = contextPath == null ? pathPattern : contextPath + pathPattern;
service(Route.builder().path(prefixed).build(), service);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of adding the prefix here, what do you think of adding the prefix at VirtualHostBuilder#build?

If we add routes here there are several problems:

  1. Some builders like VirtualHostBuilder#route(), or VirtualHostBuilder#annotatedService, VirtualHostBuilder#serviceUnder won't be applied
  2. If the contextPath value changes, then the applied prefix can be inconsistent

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks. understood!

@@ -677,9 +701,29 @@ private List<ServiceConfigSetters> getServiceConfigSetters(
} else {
serviceConfigSetters = ImmutableList.copyOf(this.serviceConfigSetters);
}

if (defaultVirtualHostBuilder.contextPath() != null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto, I propose that we add prefixes at VirtualHostBuilder#build.

In detail, I propose the following change so that ServiceConfigBuilder accepts the prefix before creating the actual ServiceConfig.

+++ b/core/src/main/java/com/linecorp/armeria/server/ServiceConfigBuilder.java
@@ -293,7 +293,8 @@ final class ServiceConfigBuilder implements ServiceConfigSetters {
                         HttpHeaders virtualHostDefaultHeaders,
                         Function<? super RoutingContext, ? extends RequestId> defaultRequestIdGenerator,
                         ServiceErrorHandler defaultServiceErrorHandler,
-                        @Nullable UnhandledExceptionsReporter unhandledExceptionsReporter) {
+                        @Nullable UnhandledExceptionsReporter unhandledExceptionsReporter,
+                        String contextPath) {
         ServiceErrorHandler errorHandler =
                 serviceErrorHandler != null ? serviceErrorHandler.orElse(defaultServiceErrorHandler)
                                             : defaultServiceErrorHandler;
@@ -303,7 +304,7 @@ final class ServiceConfigBuilder implements ServiceConfigSetters {
         }
 
         return new ServiceConfig(
-                route, mappedRoute == null ? route : mappedRoute,
+                route.withPrefix(contextPath), mappedRoute == null ? route : mappedRoute,

This can be passed from VirtualHostBuilder like the following:

+++ b/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java
@@ -1338,7 +1337,7 @@ public final class VirtualHostBuilder implements TlsSetters {
                                             successFunction, requestAutoAbortDelayMillis,
                                             multipartUploadsLocation, defaultHeaders,
                                             requestIdGenerator, defaultErrorHandler,
-                                            unhandledExceptionsReporter);
+                                            unhandledExceptionsReporter, contextPath);
                 }).collect(toImmutableList());
 
         final ServiceConfig fallbackServiceConfig =
@@ -1347,7 +1346,7 @@ public final class VirtualHostBuilder implements TlsSetters {
                                accessLogWriter, blockingTaskExecutor, successFunction,
                                requestAutoAbortDelayMillis, multipartUploadsLocation,
                                defaultHeaders, requestIdGenerator,
-                               defaultErrorHandler, unhandledExceptionsReporter);
+                               defaultErrorHandler, unhandledExceptionsReporter, "/");

Note that fallbackServiceConfig should still catch all requests, so the prefix isn't applied at line 1346.

@@ -320,6 +320,10 @@ ServiceConfig build(ServiceNaming defaultServiceNaming,
requestIdGenerator != null ? requestIdGenerator : defaultRequestIdGenerator, errorHandler);
}

public ServiceConfigBuilder getPrefixedServiceConfigBuilder(String contextPath) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this method is necessary if we can set the contextPath when ServiceConfigBuilder#build is invoked.

@@ -122,6 +123,8 @@ public final class VirtualHostBuilder implements TlsSetters {
private String hostnamePattern;
private int port = -1;
@Nullable
private String contextPath;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just set to / by default and remove the @Nullable contract?

@Test
void virtualHostContextPath() {
final Server server = Server
.builder()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should also consider decorators:

Server.builder()
  .decorator("/asdf", (delegate, ctx, req) -> HttpResponse.of(200))
...

We probably need to apply the contextPath like the following:

@@ -737,7 +737,7 @@ public final class VirtualHostBuilder implements TlsSetters {
 
     @Nullable
     private Function<? super HttpService, ? extends HttpService> getRouteDecoratingService(
-            @Nullable VirtualHostBuilder defaultVirtualHostBuilder) {
+            @Nullable VirtualHostBuilder defaultVirtualHostBuilder, String contextPath) {
         final List<RouteDecoratingService> routeDecoratingServices;
         if (defaultVirtualHostBuilder != null) {
             routeDecoratingServices = ImmutableList.<RouteDecoratingService>builder()
@@ -749,8 +749,11 @@ public final class VirtualHostBuilder implements TlsSetters {
         }
 
         if (!routeDecoratingServices.isEmpty()) {
-            return RouteDecoratingService.newDecorator(
-                    Routers.ofRouteDecoratingService(routeDecoratingServices));
+            final List<RouteDecoratingService> prefixed =
+                    routeDecoratingServices.stream()
+                                           .map(service -> service.withRoutePrefix(contextPath))
+                                           .collect(Collectors.toList());
+            return RouteDecoratingService.newDecorator(Routers.ofRouteDecoratingService(prefixed));
         } else {
             return null;
         }

where the routes can be overridden using delegation

+++ b/core/src/main/java/com/linecorp/armeria/internal/server/RouteDecoratingService.java
@@ -91,6 +91,15 @@ public final class RouteDecoratingService implements HttpService {
         decorator = requireNonNull(decoratorFunction, "decoratorFunction").apply(this);
     }
 
+    public RouteDecoratingService(Route route, HttpService decorator) {
+        this.route = requireNonNull(route, "route");
+        this.decorator = requireNonNull(decorator, "decorator");
+    }
+
+    public RouteDecoratingService withRoutePrefix(String prefix) {
+        return new RouteDecoratingService(route.withPrefix(prefix), decorator);
+    }
+

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah... decorator. thanks.

@bkkanq
Copy link
Contributor Author

bkkanq commented Jul 21, 2023

@jrhee17 nim, Thanks for the review. I'll address your comments.
let me change the status from draft to ready after adding some details to it!

@jrhee17 jrhee17 modified the milestones: 1.25.0, 1.26.0 Jul 30, 2023
- consider decorator
- rename API to baseContextPath
- handle VirtualHost separatly
- add prefixes at VirtualHostBuilder#build.
@codecov
Copy link

codecov bot commented Aug 24, 2023

Codecov Report

All modified lines are covered by tests ✅

Comparison is base (026351d) 74.16% compared to head (505d1f5) 74.17%.

Additional details and impacted files
@@             Coverage Diff              @@
##               main    #4802      +/-   ##
============================================
+ Coverage     74.16%   74.17%   +0.01%     
- Complexity    19891    19895       +4     
============================================
  Files          1707     1707              
  Lines         73325    73338      +13     
  Branches       9364     9364              
============================================
+ Hits          54381    54398      +17     
+ Misses        14511    14507       -4     
  Partials       4433     4433              
Files Coverage Δ
...rmeria/internal/server/RouteDecoratingService.java 78.00% <100.00%> (+2.44%) ⬆️
...ava/com/linecorp/armeria/server/ServerBuilder.java 83.05% <100.00%> (+0.06%) ⬆️
.../linecorp/armeria/server/ServiceConfigBuilder.java 58.46% <100.00%> (+0.32%) ⬆️
...om/linecorp/armeria/server/VirtualHostBuilder.java 82.27% <100.00%> (+0.23%) ⬆️

... and 16 files with indirect coverage changes

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@bkkanq bkkanq marked this pull request as ready for review August 24, 2023 04:08
@bkkanq bkkanq requested a review from ikhoon as a code owner August 24, 2023 04:08
Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks almost done! Only minor comments for me 🙇

@@ -335,7 +336,7 @@ ServiceConfig build(ServiceNaming defaultServiceNaming,
}

return new ServiceConfig(
route, mappedRoute == null ? route : mappedRoute,
route.withPrefix(contextPath), mappedRoute == null ? route : mappedRoute,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following the contract of mappedRoute:

* If the service is not an {@link HttpServiceWithRoutes}, the {@link Route} is the same as
* {@link #route()}.

We can probably also dedup the route.withPrefix(contextPath)

Suggested change
route.withPrefix(contextPath), mappedRoute == null ? route : mappedRoute,
route.withPrefix(contextPath), mappedRoute == null ? route.withPrefix(contextPath) : mappedRoute,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the {@link Route} is the same as {@link #route()}.

Understood... thanks for catching it out

final Server server = Server
.builder()
// 1
.port(8888, SessionProtocol.HTTP)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting a fixed port can be a cause of flakiness. Can you modify so that the port is set as random (same as the other tests in this class?)

Copy link
Contributor Author

@bkkanq bkkanq Aug 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, Is there some way to test virtualHost with port (e.g. .virtualHost("*.foo.com:8888")) without setting a fixed port? 😢

When I set the port as 0 ( to use random port) I've got the following error.

virtual host port: 8888 (expected: one of [])
java.lang.IllegalStateException: virtual host port: 8888 (expected: one of [])
	at com.google.common.base.Preconditions.checkState(Preconditions.java:715)
	at com.linecorp.armeria.server.ServerBuilder.buildServerConfig(ServerBuilder.java:2056)
	at com.linecorp.armeria.server.ServerBuilder.build(ServerBuilder.java:2002)
	at com.linecorp.armeria.server.ServerBuilderTest.baseContextPathApplied(ServerBuilderTest.java:740)
	at java.lang.reflect.Method.invoke(Method.java:498)

So I guessed I should set a fixed port to test it.

I've investigated test codes in this class, there's a similar test to follow.

and it's using http()

Did you mean to set the port using .http().. ?.?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see 😅 If you would like to test virtual hosts with ports, I think the following reference can help:

try (ServerSocket ss = new ServerSocket(0)) {
normalServerPort = ss.getLocalPort();
}
try (ServerSocket ss = new ServerSocket(0)) {
virtualHostPort = ss.getLocalPort();
}
try (ServerSocket ss = new ServerSocket(0)) {
fooHostPort = ss.getLocalPort();
}

Optionally since the test class is convoluted (so it is difficult to use ServerExtension), I wouldn't be against just creating a separate test class (either way is fine)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks! let me handle it.

bkkanq and others added 2 commits September 1, 2023 00:06
Co-authored-by: jrhee17 <guins_j@guins.org>
Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still looks good 👍 I think this PR is ready to go once my comment on testing is resolved

final Server server = Server
.builder()
// 1
.port(8888, SessionProtocol.HTTP)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see 😅 If you would like to test virtual hosts with ports, I think the following reference can help:

try (ServerSocket ss = new ServerSocket(0)) {
normalServerPort = ss.getLocalPort();
}
try (ServerSocket ss = new ServerSocket(0)) {
virtualHostPort = ss.getLocalPort();
}
try (ServerSocket ss = new ServerSocket(0)) {
fooHostPort = ss.getLocalPort();
}

Optionally since the test class is convoluted (so it is difficult to use ServerExtension), I wouldn't be against just creating a separate test class (either way is fine)

Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes look good to me! Thanks for your patience @bkkanq 🙇 🚀 🙇

Note that I've pushed a small commit which renames contextPath -> baseContextPath

Copy link
Member

@minwoox minwoox left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great!
Thanks, @bkkanq!
Also thanks, @jrhee17 for taking care of it. 😉

@@ -335,7 +336,8 @@ ServiceConfig build(ServiceNaming defaultServiceNaming,
}

return new ServiceConfig(
route, mappedRoute == null ? route : mappedRoute,
route.withPrefix(baseContextPath),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit; we can extract the route:

final Route withPrefix = route.withPrefix(baseContextPath);
return new ServiceConfig(
        withPrefix, mappedRoute == null ? withPrefix : mappedRoute,

Routers.ofRouteDecoratingService(routeDecoratingServices));
final List<RouteDecoratingService> prefixed = routeDecoratingServices.stream()
.map(service -> service.withRoutePrefix(baseContextPath))
.collect(Collectors.toList());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually use:

Suggested change
.collect(Collectors.toList());
.collect(toImmutableList());

import com.linecorp.armeria.internal.testing.MockAddressResolverGroup;
import com.linecorp.armeria.testing.junit5.server.ServerExtension;

public class BaseContextPathTest {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove public modifiers in this class.
Let's also add @FlakyTest because this can be flaky.

Copy link
Contributor

@ikhoon ikhoon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot, @bkkanq and @jrhee17. It is a long-awaited feature. Many users will be happy with this. 🙇‍♂️🙇‍♂️

@ikhoon ikhoon merged commit d9b6142 into line:main Oct 13, 2023
15 checks passed
Bue-von-hon pushed a commit to Bue-von-hon/armeria that referenced this pull request Nov 10, 2023
Motivation:

Described in line#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 line#3591

Co-authored-by: jrhee17 <guins_j@guins.org>
Co-authored-by: Ikhun Um <ih.pert@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Provide a way to set the root context path
5 participants