From cf0c326ab6eeedd91f5b9f9bd4a9bb10d55731fb Mon Sep 17 00:00:00 2001 From: Mikko Karjalainen Date: Wed, 22 May 2019 09:28:56 -0400 Subject: [PATCH] Adds a path prefix routing object. (#412) --- .../routing/config/RoutingObjectFactory.java | 83 ++++-- .../routing/handlers/ConditionRouter.java | 1 - .../routing/handlers/PathPrefixRouter.java | 121 +++++++++ .../styx/routing/handlers/RouteRefLookup.java | 60 ++++ .../styx/startup/StyxServerComponents.java | 4 + .../handlers/RoutingObjectHandlerTest.kt | 130 +++------ .../kotlin/com/hotels/styx/routing/Support.kt | 8 +- .../config/RoutingObjectFactoryTest.kt | 57 +++- .../handlers/BackendServiceProxyTest.kt | 27 +- .../handlers/ConditionRouterConfigTest.kt | 52 ++-- .../handlers/HttpInterceptorPipelineTest.kt | 50 ++-- .../routing/handlers/PathPrefixRouterTest.kt | 257 ++++++++++++++++++ .../routing/handlers/ProxyToBackendTest.kt | 14 +- .../routing/handlers/RouteRefLookupTest.kt | 88 ++++++ .../handlers/StaticResponseHandlerTest.kt | 9 +- .../interceptors/RewriteInterceptorTest.kt | 14 +- .../hotels/styx/server/PathPrefixRouter.java | 40 --- .../styx/routing/PathPrefixRoutingSpec.kt | 120 ++++++++ 18 files changed, 866 insertions(+), 269 deletions(-) create mode 100644 components/proxy/src/main/java/com/hotels/styx/routing/handlers/PathPrefixRouter.java create mode 100644 components/proxy/src/main/java/com/hotels/styx/routing/handlers/RouteRefLookup.java create mode 100644 components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/PathPrefixRouterTest.kt create mode 100644 components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/RouteRefLookupTest.kt delete mode 100644 components/server/src/main/java/com/hotels/styx/server/PathPrefixRouter.java create mode 100644 system-tests/ft-suite/src/test/kotlin/com/hotels/styx/routing/PathPrefixRoutingSpec.kt diff --git a/components/proxy/src/main/java/com/hotels/styx/routing/config/RoutingObjectFactory.java b/components/proxy/src/main/java/com/hotels/styx/routing/config/RoutingObjectFactory.java index 9e60ba38f6..53ac25853d 100644 --- a/components/proxy/src/main/java/com/hotels/styx/routing/config/RoutingObjectFactory.java +++ b/components/proxy/src/main/java/com/hotels/styx/routing/config/RoutingObjectFactory.java @@ -16,6 +16,7 @@ package com.hotels.styx.routing.config; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.hotels.styx.Environment; import com.hotels.styx.api.Eventual; @@ -26,7 +27,9 @@ import com.hotels.styx.routing.db.StyxObjectStore; import com.hotels.styx.routing.handlers.ConditionRouter; import com.hotels.styx.routing.handlers.HttpInterceptorPipeline; +import com.hotels.styx.routing.handlers.PathPrefixRouter; import com.hotels.styx.routing.handlers.ProxyToBackend; +import com.hotels.styx.routing.handlers.RouteRefLookup; import com.hotels.styx.routing.handlers.StaticResponseHandler; import java.util.List; @@ -44,28 +47,38 @@ */ public class RoutingObjectFactory { public static final ImmutableMap BUILTIN_HANDLER_SCHEMAS; - private static final ImmutableMap BUILTIN_HANDLER_FACTORIES; + public static final ImmutableMap BUILTIN_HANDLER_FACTORIES; + public static final RouteRefLookup DEFAULT_REFERENCE_LOOKUP = reference -> (request, ctx) -> + Eventual.of(response(NOT_FOUND) + .body(format("Handler not found for '%s'.", reference), UTF_8) + .build() + .stream()); private static final String STATIC_RESPONSE = "StaticResponseHandler"; private static final String CONDITION_ROUTER = "ConditionRouter"; private static final String INTERCEPTOR_PIPELINE = "InterceptorPipeline"; private static final String PROXY_TO_BACKEND = "ProxyToBackend"; + private static final String PATH_PREFIX_ROUTER = "PathPrefixRouter"; + static { BUILTIN_HANDLER_FACTORIES = ImmutableMap.builder() - .put(STATIC_RESPONSE, new StaticResponseHandler.Factory()) - .put(CONDITION_ROUTER, new ConditionRouter.Factory()) - .put(INTERCEPTOR_PIPELINE, new HttpInterceptorPipeline.Factory()) - .put(PROXY_TO_BACKEND, new ProxyToBackend.Factory()) - .build(); + .put(STATIC_RESPONSE, new StaticResponseHandler.Factory()) + .put(CONDITION_ROUTER, new ConditionRouter.Factory()) + .put(INTERCEPTOR_PIPELINE, new HttpInterceptorPipeline.Factory()) + .put(PROXY_TO_BACKEND, new ProxyToBackend.Factory()) + .put(PATH_PREFIX_ROUTER, new PathPrefixRouter.Factory()) + .build(); BUILTIN_HANDLER_SCHEMAS = ImmutableMap.builder() - .put(STATIC_RESPONSE, StaticResponseHandler.SCHEMA) - .put(CONDITION_ROUTER, ConditionRouter.SCHEMA) - .put(INTERCEPTOR_PIPELINE, HttpInterceptorPipeline.SCHEMA) - .put(PROXY_TO_BACKEND, ProxyToBackend.SCHEMA) - .build(); + .put(STATIC_RESPONSE, StaticResponseHandler.SCHEMA) + .put(CONDITION_ROUTER, ConditionRouter.SCHEMA) + .put(INTERCEPTOR_PIPELINE, HttpInterceptorPipeline.SCHEMA) + .put(PROXY_TO_BACKEND, ProxyToBackend.SCHEMA) + .put(PATH_PREFIX_ROUTER, PathPrefixRouter.SCHEMA) + .build(); } + private final RouteRefLookup refLookup; private final Environment environment; private final StyxObjectStore routeObjectStore; private final Iterable plugins; @@ -75,11 +88,14 @@ public class RoutingObjectFactory { @VisibleForTesting public RoutingObjectFactory( + RouteRefLookup refLookup, Map builtInObjectTypes, Environment environment, - StyxObjectStore routeObjectStore, Iterable plugins, + StyxObjectStore routeObjectStore, + Iterable plugins, BuiltinInterceptorsFactory interceptorFactory, boolean requestTracking) { + this.refLookup = requireNonNull(refLookup); this.builtInObjectTypes = requireNonNull(builtInObjectTypes); this.environment = requireNonNull(environment); this.routeObjectStore = requireNonNull(routeObjectStore); @@ -88,13 +104,30 @@ public RoutingObjectFactory( this.requestTracking = requestTracking; } - public RoutingObjectFactory( - Environment environment, - StyxObjectStore routeObjectStore, - List plugins, - BuiltinInterceptorsFactory interceptorFactory, - boolean requestTracking) { - this(BUILTIN_HANDLER_FACTORIES, environment, routeObjectStore, plugins, interceptorFactory, requestTracking); + public RoutingObjectFactory(StyxObjectStore routeObjectStore) { + this(DEFAULT_REFERENCE_LOOKUP, BUILTIN_HANDLER_FACTORIES, new Environment.Builder().build(), routeObjectStore, ImmutableList.of(), new BuiltinInterceptorsFactory(), false); + } + + public RoutingObjectFactory(RouteRefLookup refLookup, Map handlerFactories) { + this(refLookup, handlerFactories, new Environment.Builder().build(), new StyxObjectStore<>(), ImmutableList.of(), new BuiltinInterceptorsFactory(), false); + } + + public RoutingObjectFactory(RouteRefLookup refLookup) { + this(refLookup, BUILTIN_HANDLER_FACTORIES, new Environment.Builder().build(), new StyxObjectStore<>(), ImmutableList.of(), new BuiltinInterceptorsFactory(), false); + } + + public RoutingObjectFactory() { + this(DEFAULT_REFERENCE_LOOKUP, + BUILTIN_HANDLER_FACTORIES, + new Environment.Builder().build(), + new StyxObjectStore<>(), + ImmutableList.of(), + new BuiltinInterceptorsFactory(), + false); + } + + public HttpHandler build(RoutingObjectConfiguration configNode) { + return build(ImmutableList.of(), configNode); } public HttpHandler build(List parents, RoutingObjectConfiguration configNode) { @@ -115,15 +148,9 @@ public HttpHandler build(List parents, RoutingObjectConfiguration config return factory.build(parents, context, configBlock); } else if (configNode instanceof RoutingObjectReference) { - RoutingObjectReference reference = (RoutingObjectReference) configNode; - - return (request, context) -> routeObjectStore.get(reference.name()) - .map(handler -> handler.getHandler().handle(request, context)) - .orElse(Eventual.of( - response(NOT_FOUND) - .body("Not found: " + String.join(".", parents) + "." + reference.name(), UTF_8) - .build() - .stream())); + return (request, context) -> refLookup + .apply((RoutingObjectReference) configNode) + .handle(request, context); } else { throw new UnsupportedOperationException(format("Unsupported configuration node type: '%s'", configNode.getClass().getName())); } diff --git a/components/proxy/src/main/java/com/hotels/styx/routing/handlers/ConditionRouter.java b/components/proxy/src/main/java/com/hotels/styx/routing/handlers/ConditionRouter.java index 3bc9cee51d..b02ee8a148 100644 --- a/components/proxy/src/main/java/com/hotels/styx/routing/handlers/ConditionRouter.java +++ b/components/proxy/src/main/java/com/hotels/styx/routing/handlers/ConditionRouter.java @@ -111,7 +111,6 @@ private ConditionRouterConfig(@JsonProperty("routes") List routes = new ConcurrentSkipListMap<>( + comparingInt(String::length) + .reversed() + .thenComparing(naturalOrder()) + ); + + PathPrefixRouter(List> routes) { + routes.forEach(entry -> this.routes.put(entry.key(), entry.value())); + } + + public Optional route(LiveHttpRequest request) { + String path = request.path(); + + return routes.entrySet().stream() + .filter(entry -> path.startsWith(entry.getKey())) + .findFirst() + .map(Map.Entry::getValue); + } + + public static class Factory implements HttpHandlerFactory { + + @Override + public HttpHandler build(List parents, Context context, RoutingObjectDefinition configBlock) { + PathPrefixRouterConfig config = new JsonNodeConfig(configBlock.config()).as(PathPrefixRouterConfig.class); + if (config.routes == null) { + throw missingAttributeError(configBlock, join(".", parents), "routes"); + } + + PathPrefixRouter pathPrefixRouter = new PathPrefixRouter( + config.routes.stream() + .map(route -> pair(route.prefix, context.factory().build(toRoutingConfigNode(route.destination)))) + .collect(toList()) + ); + + return (request, ctx) -> pathPrefixRouter.route(request) + .orElse((x, y) -> Eventual.error(new NoServiceConfiguredException(request.path()))) + .handle(request, ctx); + } + + private static class PathPrefixConfig { + private final String prefix; + private final JsonNode destination; + + public PathPrefixConfig(@JsonProperty("prefix") String prefix, + @JsonProperty("destination") JsonNode destination) { + this.prefix = prefix; + this.destination = destination; + } + } + + private static class PathPrefixRouterConfig { + private final List routes; + + public PathPrefixRouterConfig(@JsonProperty("routes") List routes) { + this.routes = routes; + } + } + } + +} diff --git a/components/proxy/src/main/java/com/hotels/styx/routing/handlers/RouteRefLookup.java b/components/proxy/src/main/java/com/hotels/styx/routing/handlers/RouteRefLookup.java new file mode 100644 index 0000000000..c4c31b626b --- /dev/null +++ b/components/proxy/src/main/java/com/hotels/styx/routing/handlers/RouteRefLookup.java @@ -0,0 +1,60 @@ +/* + Copyright (C) 2013-2019 Expedia Inc. + + Licensed 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 + + http://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.hotels.styx.routing.handlers; + +import com.hotels.styx.api.Eventual; +import com.hotels.styx.api.HttpHandler; +import com.hotels.styx.routing.RoutingObjectRecord; +import com.hotels.styx.routing.config.RoutingObjectReference; +import com.hotels.styx.routing.db.StyxObjectStore; + +import static com.hotels.styx.api.HttpResponse.response; +import static com.hotels.styx.api.HttpResponseStatus.NOT_FOUND; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Objects.requireNonNull; + +/** + * Resolves a routing object reference from route database. + */ +public interface RouteRefLookup { + HttpHandler apply(RoutingObjectReference route); + + /** + * A StyxObjectStore based route reference lookup function. + */ + class RouteDbRefLookup implements RouteRefLookup { + private final StyxObjectStore routeDatabase; + + public RouteDbRefLookup(StyxObjectStore routeDatabase) { + this.routeDatabase = requireNonNull(routeDatabase); + } + + @Override + public HttpHandler apply(RoutingObjectReference route) { + return this.routeDatabase.get(route.name()) + .map(RoutingObjectRecord::getHandler) + .orElse((liveRequest, na) -> { + liveRequest.consume(); + + return Eventual.of(response(NOT_FOUND) + .body("Not found: " + route.name(), UTF_8) + .build() + .stream() + ); + }); + } + } +} diff --git a/components/proxy/src/main/java/com/hotels/styx/startup/StyxServerComponents.java b/components/proxy/src/main/java/com/hotels/styx/startup/StyxServerComponents.java index 0317b6f1b9..649b3b83b7 100644 --- a/components/proxy/src/main/java/com/hotels/styx/startup/StyxServerComponents.java +++ b/components/proxy/src/main/java/com/hotels/styx/startup/StyxServerComponents.java @@ -36,6 +36,7 @@ import com.hotels.styx.routing.config.RoutingObjectDefinition; import com.hotels.styx.routing.config.RoutingObjectFactory; import com.hotels.styx.routing.db.StyxObjectStore; +import com.hotels.styx.routing.handlers.RouteRefLookup.RouteDbRefLookup; import com.hotels.styx.startup.extensions.ConfiguredPluginFactory; import java.util.HashMap; @@ -44,6 +45,7 @@ import static com.hotels.styx.Version.readVersionFrom; import static com.hotels.styx.infrastructure.logging.LOGBackConfigurer.initLogging; +import static com.hotels.styx.routing.config.RoutingObjectFactory.BUILTIN_HANDLER_FACTORIES; import static com.hotels.styx.startup.ServicesLoader.SERVICES_FROM_CONFIG; import static com.hotels.styx.startup.StyxServerComponents.LoggingSetUp.DO_NOT_MODIFY; import static com.hotels.styx.startup.extensions.PluginLoadingForStartup.loadPlugins; @@ -74,6 +76,8 @@ private StyxServerComponents(Builder builder) { : loadPlugins(environment, builder.configuredPluginFactories); this.routingObjectFactory = new RoutingObjectFactory( + new RouteDbRefLookup(this.routeObjectStore), + BUILTIN_HANDLER_FACTORIES, this.environment, this.routeObjectStore, this.plugins, diff --git a/components/proxy/src/test/kotlin/com/hotels/styx/admin/handlers/RoutingObjectHandlerTest.kt b/components/proxy/src/test/kotlin/com/hotels/styx/admin/handlers/RoutingObjectHandlerTest.kt index 5cadcf4575..e73d86874e 100644 --- a/components/proxy/src/test/kotlin/com/hotels/styx/admin/handlers/RoutingObjectHandlerTest.kt +++ b/components/proxy/src/test/kotlin/com/hotels/styx/admin/handlers/RoutingObjectHandlerTest.kt @@ -16,10 +16,8 @@ package com.hotels.styx.admin.handlers import com.fasterxml.jackson.databind.node.ObjectNode -import com.hotels.styx.Environment -import com.hotels.styx.api.Eventual -import com.hotels.styx.api.HttpRequest import com.hotels.styx.api.HttpRequest.delete +import com.hotels.styx.api.HttpRequest.get import com.hotels.styx.api.HttpRequest.put import com.hotels.styx.api.HttpResponseStatus.CREATED import com.hotels.styx.api.HttpResponseStatus.NOT_FOUND @@ -27,23 +25,27 @@ import com.hotels.styx.api.HttpResponseStatus.OK import com.hotels.styx.routing.RoutingObjectRecord import com.hotels.styx.routing.config.RoutingObjectFactory import com.hotels.styx.routing.db.StyxObjectStore +import com.hotels.styx.routing.handle import com.hotels.styx.routing.handlers.StaticResponseHandler -import com.hotels.styx.server.HttpInterceptorContext import io.kotlintest.matchers.types.shouldBeTypeOf import io.kotlintest.matchers.types.shouldNotBeSameInstanceAs import io.kotlintest.shouldBe import io.kotlintest.specs.FeatureSpec -import io.mockk.mockk import reactor.core.publisher.toMono import java.nio.charset.StandardCharsets.UTF_8 class RoutingObjectHandlerTest : FeatureSpec({ - val environment = Environment.Builder().build() - val routeDatabase = StyxObjectStore() - val objectFactory = RoutingObjectFactory(environment, routeDatabase, listOf(), mockk(), true) + val objectFactory = RoutingObjectFactory(routeDatabase) + + val staticResponseObject = """ + type: "StaticResponseHandler" + config: + status: 200 + content: "Hello, world!" + """.trimIndent() feature("Route database management") { scenario("Injecting new objects") { @@ -51,18 +53,8 @@ class RoutingObjectHandlerTest : FeatureSpec({ handler.handle( put("/admin/routing/objects/staticResponse") - .body(""" - name: "staticResponse" - type: "StaticResponseHandler" - tags: [] - config: - status: 200 - content: "Hello, world!" - """.trimIndent(), - UTF_8) - .build() - .stream(), HttpInterceptorContext.create()) - .flatMap { it.aggregate(2000) } + .body(staticResponseObject, UTF_8) + .build()) .toMono() .block() ?.status() shouldBe CREATED @@ -78,22 +70,13 @@ class RoutingObjectHandlerTest : FeatureSpec({ handler.handle( put("/admin/routing/objects/staticResponse") - .body(""" - type: StaticResponseHandler - config: - status: 200 - content: "Hello, world!" - """.trimIndent(), - UTF_8) - .build() - .stream(), HttpInterceptorContext.create()) - .flatMap { it.aggregate(2000) } + .body(staticResponseObject, UTF_8) + .build()) .toMono() .block() ?.status() shouldBe CREATED - val response = handler.handle(HttpRequest.get("/admin/routing/objects/staticResponse").build().stream(), HttpInterceptorContext.create()) - .flatMap { it.aggregate(2000) } + val response = handler.handle(get("/admin/routing/objects/staticResponse").build()) .toMono() .block() @@ -116,16 +99,8 @@ class RoutingObjectHandlerTest : FeatureSpec({ handler.handle( put("/admin/routing/objects/staticResponse") - .body(""" - type: StaticResponseHandler - config: - status: 200 - content: "Hello, world!" - """.trimIndent(), - UTF_8) - .build() - .stream(), HttpInterceptorContext.create()) - .flatMap { it.aggregate(2000) } + .body(staticResponseObject, UTF_8) + .build()) .toMono() .block() ?.status() shouldBe CREATED @@ -133,17 +108,14 @@ class RoutingObjectHandlerTest : FeatureSpec({ val r = handler.handle( put("/admin/routing/objects/conditionRouter") .body(""" - type: ConditionRouter - config: - routes: - - condition: path() == "/bar" - destination: b - fallback: fb - """.trimIndent(), - UTF_8) - .build() - .stream(), HttpInterceptorContext.create()) - .flatMap { it.aggregate(2000) } + type: ConditionRouter + config: + routes: + - condition: path() == "/bar" + destination: b + fallback: fb + """.trimIndent(), UTF_8) + .build()) .toMono() .block() @@ -151,10 +123,7 @@ class RoutingObjectHandlerTest : FeatureSpec({ r?.status() shouldBe CREATED - val response = handler.handle( - HttpRequest.get("/admin/routing/objects").build() - .stream(), HttpInterceptorContext.create()) - .flatMap { it.aggregate(2000) } + val response = handler.handle(get("/admin/routing/objects").build()) .toMono() .block() @@ -186,16 +155,8 @@ class RoutingObjectHandlerTest : FeatureSpec({ handler.handle( put("/admin/routing/objects/staticResponse") - .body(""" - type: StaticResponseHandler - config: - status: 200 - content: "Hello, world!" - """.trimIndent(), - UTF_8) - .build() - .stream(), HttpInterceptorContext.create()) - .flatMap { it.aggregate(2000) } + .body(staticResponseObject, UTF_8) + .build()) .toMono() .block() ?.status() shouldBe CREATED @@ -207,15 +168,12 @@ class RoutingObjectHandlerTest : FeatureSpec({ handler.handle( put("/admin/routing/objects/staticResponse") .body(""" - type: StaticResponseHandler - config: - status: 200 - content: "Hey man!" - """.trimIndent(), - UTF_8) - .build() - .stream(), HttpInterceptorContext.create()) - .flatMap { it.aggregate(2000) } + type: StaticResponseHandler + config: + status: 200 + content: "Hey man!" + """.trimIndent(), UTF_8) + .build()) .toMono() .block() ?.status() shouldBe CREATED @@ -231,16 +189,8 @@ class RoutingObjectHandlerTest : FeatureSpec({ handler.handle( put("/admin/routing/objects/staticResponse") - .body(""" - type: StaticResponseHandler - config: - status: 200 - content: "Hello, world!" - """.trimIndent(), - UTF_8) - .build() - .stream(), HttpInterceptorContext.create()) - .flatMap { it.aggregate(2000) } + .body(staticResponseObject, UTF_8) + .build()) .toMono() .block() ?.status() shouldBe CREATED @@ -248,9 +198,7 @@ class RoutingObjectHandlerTest : FeatureSpec({ routeDatabase.get("staticResponse").isPresent shouldBe true handler.handle( - delete("/admin/routing/objects/staticResponse").build().stream(), - HttpInterceptorContext.create()) - .flatMap { it.aggregate(2000) } + delete("/admin/routing/objects/staticResponse").build()) .toMono() .block() ?.status() shouldBe OK @@ -262,9 +210,7 @@ class RoutingObjectHandlerTest : FeatureSpec({ val handler = RoutingObjectHandler(routeDatabase, objectFactory) handler.handle( - delete("/admin/routing/objects/staticResponse").build().stream(), - HttpInterceptorContext.create()) - .flatMap { it.aggregate(2000) } + delete("/admin/routing/objects/staticResponse").build()) .toMono() .block() ?.status() shouldBe NOT_FOUND diff --git a/components/proxy/src/test/kotlin/com/hotels/styx/routing/Support.kt b/components/proxy/src/test/kotlin/com/hotels/styx/routing/Support.kt index 7b2e4568b3..d42c03480b 100644 --- a/components/proxy/src/test/kotlin/com/hotels/styx/routing/Support.kt +++ b/components/proxy/src/test/kotlin/com/hotels/styx/routing/Support.kt @@ -16,6 +16,8 @@ package com.hotels.styx.routing import com.hotels.styx.Environment +import com.hotels.styx.api.HttpHandler +import com.hotels.styx.api.HttpRequest import com.hotels.styx.infrastructure.configuration.yaml.YamlConfig import com.hotels.styx.proxy.plugin.NamedPlugin import com.hotels.styx.routing.config.BuiltinInterceptorsFactory @@ -23,9 +25,10 @@ import com.hotels.styx.routing.config.HttpHandlerFactory import com.hotels.styx.routing.config.RoutingObjectDefinition import com.hotels.styx.routing.config.RoutingObjectFactory import com.hotels.styx.routing.db.StyxObjectStore +import com.hotels.styx.server.HttpInterceptorContext import io.mockk.mockk -fun configBlock(text: String) = YamlConfig(text).get("config", RoutingObjectDefinition::class.java).get() +fun routingObjectDef(text: String) = YamlConfig(text).`as`((RoutingObjectDefinition::class.java)) data class RoutingContext( val environment: Environment = mockk(), @@ -36,3 +39,6 @@ data class RoutingContext( val requestTracking: Boolean = false) { fun get() = HttpHandlerFactory.Context(environment, routeDb, routingObjectFactory, plugins, interceptorsFactory, requestTracking) } + +fun HttpHandler.handle(request: HttpRequest, count: Int = 10000) = this.handle(request.stream(), HttpInterceptorContext.create()) + .flatMap { it.aggregate(count) } diff --git a/components/proxy/src/test/kotlin/com/hotels/styx/routing/config/RoutingObjectFactoryTest.kt b/components/proxy/src/test/kotlin/com/hotels/styx/routing/config/RoutingObjectFactoryTest.kt index 23afbf4999..0b8056249f 100644 --- a/components/proxy/src/test/kotlin/com/hotels/styx/routing/config/RoutingObjectFactoryTest.kt +++ b/components/proxy/src/test/kotlin/com/hotels/styx/routing/config/RoutingObjectFactoryTest.kt @@ -17,19 +17,23 @@ package com.hotels.styx.routing.config import com.hotels.styx.api.Eventual import com.hotels.styx.api.HttpHandler +import com.hotels.styx.api.HttpRequest.get +import com.hotels.styx.api.HttpResponse.response import com.hotels.styx.api.HttpResponseStatus.OK -import com.hotels.styx.api.LiveHttpRequest import com.hotels.styx.api.LiveHttpResponse import com.hotels.styx.routing.RoutingObjectRecord +import com.hotels.styx.routing.config.RoutingObjectFactory.DEFAULT_REFERENCE_LOOKUP import com.hotels.styx.routing.db.StyxObjectStore +import com.hotels.styx.routing.handlers.RouteRefLookup import com.hotels.styx.server.HttpInterceptorContext +import io.kotlintest.matchers.withClue import io.kotlintest.shouldBe import io.kotlintest.shouldThrow import io.kotlintest.specs.StringSpec import io.mockk.every import io.mockk.mockk import io.mockk.verify -import reactor.core.publisher.Mono +import reactor.core.publisher.toMono import java.util.Optional class RoutingObjectFactoryTest : StringSpec({ @@ -42,14 +46,17 @@ class RoutingObjectFactoryTest : StringSpec({ every { routeObjectStore.get("aHandler") } returns Optional.of(RoutingObjectRecord("type", mockk(), mockHandler)) "Builds a new handler as per RoutingObjectDefinition" { - val routeDef = RoutingObjectDefinition("handler-def", "DelegateHandler", mockk()) + val routeDef = RoutingObjectDefinition("handler-def", "SomeRoutingObject", mockk()) val handlerFactory = httpHandlerFactory(mockHandler) - val routeFactory = RoutingObjectFactory(mapOf("DelegateHandler" to handlerFactory), mockk(), routeObjectStore, mockk(), mockk(), false) + val routingObjectFactory = RoutingObjectFactory( + DEFAULT_REFERENCE_LOOKUP, + mapOf("SomeRoutingObject" to handlerFactory)) - val delegateHandler = routeFactory.build(listOf("parents"), routeDef) - - (delegateHandler != null).shouldBe(true) + withClue("Should create a routing object handler") { + val handler = routingObjectFactory.build(listOf("parents"), routeDef) + (handler != null).shouldBe(true) + } verify { handlerFactory.build(listOf("parents"), any(), routeDef) @@ -58,20 +65,44 @@ class RoutingObjectFactoryTest : StringSpec({ "Doesn't accept unregistered types" { val config = RoutingObjectDefinition("foo", "ConfigType", mockk()) - val routeFactory = RoutingObjectFactory(mapOf(), mockk(), routeObjectStore, mockk(), mockk(), false) + val routingObjectFactory = RoutingObjectFactory(routeObjectStore) val e = shouldThrow { - routeFactory.build(listOf(), config) + routingObjectFactory.build(listOf(), config) } e.message.shouldBe("Unknown handler type 'ConfigType'") } "Returns handler from a configuration reference" { - val routeFactory = RoutingObjectFactory(mapOf(), mockk(), routeObjectStore, mockk(), mockk(), false) - val handler = routeFactory.build(listOf(), RoutingObjectReference("aHandler")) - val response = Mono.from(handler.handle(LiveHttpRequest.get("/").build(), HttpInterceptorContext.create())).block() - response?.status()?.code() shouldBe (200) + val routeDb = mapOf("aHandler" to HttpHandler { request, context -> Eventual.of(response(OK).build().stream()) }) + val routingObjectFactory = RoutingObjectFactory( { ref -> routeDb[ref.name()] } ) + + val handler = routingObjectFactory.build(listOf(), RoutingObjectReference("aHandler")) + + val response = handler.handle(get("/").build().stream(), HttpInterceptorContext.create()) + .toMono() + .block() + + response?.status() shouldBe (OK) + } + + "Looks up handler for every request" { + val referenceLookup = mockk() + every {referenceLookup.apply(RoutingObjectReference("aHandler")) } returns HttpHandler { request, context -> Eventual.of(response(OK).build().stream()) } + + val routingObjectFactory = RoutingObjectFactory(referenceLookup) + + val handler = routingObjectFactory.build(listOf(), RoutingObjectReference("aHandler")) + + handler.handle(get("/").build().stream(), HttpInterceptorContext.create()).toMono().block() + handler.handle(get("/").build().stream(), HttpInterceptorContext.create()).toMono().block() + handler.handle(get("/").build().stream(), HttpInterceptorContext.create()).toMono().block() + handler.handle(get("/").build().stream(), HttpInterceptorContext.create()).toMono().block() + + verify(exactly = 4) { + referenceLookup.apply(RoutingObjectReference("aHandler")) + } } }) diff --git a/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/BackendServiceProxyTest.kt b/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/BackendServiceProxyTest.kt index 763ec3e3b6..de2cf842dc 100644 --- a/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/BackendServiceProxyTest.kt +++ b/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/BackendServiceProxyTest.kt @@ -27,12 +27,13 @@ import com.hotels.styx.api.extension.service.spi.Registry.ReloadResult.reloaded import com.hotels.styx.client.BackendServiceClient import com.hotels.styx.proxy.BackendServiceClientFactory import com.hotels.styx.routing.RoutingContext -import com.hotels.styx.routing.configBlock +import com.hotels.styx.routing.routingObjectDef import com.hotels.styx.server.HttpInterceptorContext import io.kotlintest.shouldBe import io.kotlintest.shouldThrow import io.kotlintest.specs.StringSpec import reactor.core.publisher.Mono +import reactor.core.publisher.toMono import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture.completedFuture @@ -47,8 +48,7 @@ class BackendServiceProxyTest : StringSpec({ "builds a backend service proxy from the configuration " { - val config = configBlock(""" - config: + val config = routingObjectDef(""" type: BackendServiceProxy config: backendProvider: backendServicesRegistry @@ -64,19 +64,18 @@ class BackendServiceProxyTest : StringSpec({ val handler = BackendServiceProxy.Factory(environment, clientFactory(), services).build(listOf(), context, config) backendRegistry.reload() - val hwaResponse = Mono.from(handler.handle(hwaRequest, HttpInterceptorContext.create())).block() - hwaResponse.header("X-Backend-Service").get() shouldBe("hwa") + val hwaResponse = handler.handle(hwaRequest, HttpInterceptorContext.create()).toMono().block() + hwaResponse?.header("X-Backend-Service")?.get() shouldBe("hwa") - val laResponse = Mono.from(handler.handle(laRequest, HttpInterceptorContext.create())).block() - laResponse.header("X-Backend-Service").get() shouldBe("la") + val laResponse = handler.handle(laRequest, HttpInterceptorContext.create()).toMono().block() + laResponse?.header("X-Backend-Service")?.get() shouldBe("la") - val baResponse = Mono.from(handler.handle(baRequest, HttpInterceptorContext.create())).block() - baResponse.header("X-Backend-Service").get() shouldBe("ba") + val baResponse = handler.handle(baRequest, HttpInterceptorContext.create()).toMono().block() + baResponse?.header("X-Backend-Service")?.get() shouldBe("ba") } "errors when backendProvider attribute is not specified" { - val config = configBlock(""" - config: + val config = routingObjectDef(""" type: BackendServiceProxy config: foo: bar @@ -88,11 +87,8 @@ class BackendServiceProxyTest : StringSpec({ e.message shouldBe("Routing object definition of type 'BackendServiceProxy', attribute='config.config', is missing a mandatory 'backendProvider' attribute.") } - - "errors when backendProvider does not exists" { - val config = configBlock(""" - config: + val config = routingObjectDef(""" type: BackendServiceProxy config: backendProvider: bar @@ -104,7 +100,6 @@ class BackendServiceProxyTest : StringSpec({ e.message shouldBe("No such backend service provider exists, attribute='config.config.backendProvider', name='bar'") } - }) private fun clientFactory(): BackendServiceClientFactory = BackendServiceClientFactory { diff --git a/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/ConditionRouterConfigTest.kt b/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/ConditionRouterConfigTest.kt index 43efcb93aa..274182d176 100644 --- a/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/ConditionRouterConfigTest.kt +++ b/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/ConditionRouterConfigTest.kt @@ -24,8 +24,9 @@ import com.hotels.styx.api.LiveHttpResponse.response import com.hotels.styx.routing.RoutingContext import com.hotels.styx.routing.RoutingObjectRecord import com.hotels.styx.routing.config.RoutingObjectFactory -import com.hotels.styx.routing.configBlock import com.hotels.styx.routing.db.StyxObjectStore +import com.hotels.styx.routing.handlers.RouteRefLookup.RouteDbRefLookup +import com.hotels.styx.routing.routingObjectDef import com.hotels.styx.server.HttpInterceptorContext import io.kotlintest.shouldBe import io.kotlintest.shouldThrow @@ -33,9 +34,8 @@ import io.kotlintest.specs.StringSpec import io.mockk.every import io.mockk.mockk import io.mockk.verify -import reactor.core.publisher.Mono +import reactor.core.publisher.toMono import java.util.Optional -import java.lang.IllegalArgumentException class ConditionRouterConfigTest : StringSpec({ @@ -54,15 +54,14 @@ class ConditionRouterConfigTest : StringSpec({ mockk(), HttpHandler { _, _ -> Eventual.of(response(OK).header("source", "fallback").build()) })) - val routeHandlerFactory = RoutingObjectFactory(mapOf("StaticResponseHandler" to StaticResponseHandler.Factory()), mockk(), routeObjectStore, mockk(), mockk(), false) + val routeHandlerFactory = RoutingObjectFactory(RouteDbRefLookup(routeObjectStore)) val context = RoutingContext( routeDb = routeObjectStore, routingObjectFactory = routeHandlerFactory) .get() - val config = configBlock(""" - config: + val config = routingObjectDef(""" name: main-router type: ConditionRouter config: @@ -84,22 +83,21 @@ class ConditionRouterConfigTest : StringSpec({ "Builds an instance with fallback handler" { val router = ConditionRouter.Factory().build(listOf(), context, config) - val response = Mono.from(router.handle(request, HttpInterceptorContext(true))).block() + val response = router.handle(request, HttpInterceptorContext(true)).toMono().block() - response.status() shouldBe (OK) + response?.status() shouldBe (OK) } "Builds condition router instance routes" { val router = ConditionRouter.Factory().build(listOf(), context, config) - val response = Mono.from(router.handle(request, HttpInterceptorContext())).block() + val response = router.handle(request, HttpInterceptorContext()).toMono().block() - response.status().code() shouldBe (301) + response?.status()?.code() shouldBe (301) } "Fallback handler can be specified as a handler reference" { - val router = ConditionRouter.Factory().build(listOf(), context, configBlock(""" - config: + val router = ConditionRouter.Factory().build(listOf(), context, routingObjectDef(""" name: main-router type: ConditionRouter config: @@ -109,14 +107,13 @@ class ConditionRouterConfigTest : StringSpec({ fallback: fallbackHandler """.trimIndent())) - val resp = Mono.from(router.handle(request, HttpInterceptorContext())).block() + val response = router.handle(request, HttpInterceptorContext()).toMono().block() - resp.header("source").get() shouldBe ("fallback") + response?.header("source")?.get() shouldBe ("fallback") } "Route destination can be specified as a handler reference" { - val router = ConditionRouter.Factory().build(listOf(), context, configBlock(""" - config: + val router = ConditionRouter.Factory().build(listOf(), context, routingObjectDef(""" name: main-router type: ConditionRouter config: @@ -127,16 +124,15 @@ class ConditionRouterConfigTest : StringSpec({ """.trimIndent()) ) - val resp = Mono.from(router.handle(request, HttpInterceptorContext(true))).block() - resp.header("source").get() shouldBe ("secure") + val response = router.handle(request, HttpInterceptorContext(true)).toMono().block() + response?.header("source")?.get() shouldBe ("secure") } "Throws exception when routes attribute is missing" { val e = shouldThrow { - ConditionRouter.Factory().build(listOf("config", "config"), context, configBlock(""" - config: + ConditionRouter.Factory().build(listOf("config", "config"), context, routingObjectDef(""" name: main-router type: ConditionRouter config: @@ -153,8 +149,7 @@ class ConditionRouterConfigTest : StringSpec({ "Responds with 502 Bad Gateway when fallback attribute is not specified." { - val router = ConditionRouter.Factory().build(listOf(), context, configBlock(""" - config: + val router = ConditionRouter.Factory().build(listOf(), context, routingObjectDef(""" name: main-router type: ConditionRouter config: @@ -168,16 +163,15 @@ class ConditionRouterConfigTest : StringSpec({ content: "secure" """.trimIndent())) - val resp = Mono.from(router.handle(request, HttpInterceptorContext())).block() + val response = router.handle(request, HttpInterceptorContext()).toMono().block() - resp.status() shouldBe (BAD_GATEWAY) + response?.status() shouldBe (BAD_GATEWAY) } "Indicates the condition when fails to compile an DSL expression due to Syntax Error" { val e = shouldThrow { - ConditionRouter.Factory().build(listOf("config", "config"), context, configBlock(""" - config: + ConditionRouter.Factory().build(listOf("config", "config"), context, routingObjectDef(""" name: main-router type: ConditionRouter config: @@ -197,8 +191,7 @@ class ConditionRouterConfigTest : StringSpec({ "Indicates the condition when fails to compile an DSL expression due to unrecognised DSL function name" { val e = shouldThrow { - ConditionRouter.Factory().build(listOf("config", "config"), context, configBlock(""" - config: + ConditionRouter.Factory().build(listOf("config", "config"), context, routingObjectDef(""" name: main-router type: ConditionRouter config: @@ -222,8 +215,7 @@ class ConditionRouterConfigTest : StringSpec({ val context = RoutingContext(routeDb = routeObjectStore, routingObjectFactory = builtinsFactory).get() - ConditionRouter.Factory().build(listOf("config", "config"), context, configBlock(""" - config: + ConditionRouter.Factory().build(listOf("config", "config"), context, routingObjectDef(""" name: main-router type: ConditionRouter config: diff --git a/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/HttpInterceptorPipelineTest.kt b/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/HttpInterceptorPipelineTest.kt index c3e50fad56..1990648b46 100644 --- a/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/HttpInterceptorPipelineTest.kt +++ b/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/HttpInterceptorPipelineTest.kt @@ -28,16 +28,16 @@ import com.hotels.styx.routing.RoutingObjectRecord import com.hotels.styx.routing.config.BuiltinInterceptorsFactory import com.hotels.styx.routing.config.HttpHandlerFactory import com.hotels.styx.routing.config.RoutingObjectFactory -import com.hotels.styx.routing.configBlock import com.hotels.styx.routing.db.StyxObjectStore import com.hotels.styx.routing.interceptors.RewriteInterceptor +import com.hotels.styx.routing.routingObjectDef import io.kotlintest.shouldBe import io.kotlintest.shouldThrow import io.kotlintest.specs.StringSpec import io.mockk.every import io.mockk.mockk import io.mockk.verify -import reactor.core.publisher.Mono +import reactor.core.publisher.toMono class HttpInterceptorPipelineTest : StringSpec({ val hwaRequest = LiveHttpRequest.get("/x").build() @@ -56,8 +56,7 @@ class HttpInterceptorPipelineTest : StringSpec({ routeDb = routeDatabase, routingObjectFactory = routingObjectFactory()) .get(), - configBlock(""" - config: + routingObjectDef(""" type: InterceptorPipeline config: pipeline: @@ -86,8 +85,7 @@ class HttpInterceptorPipelineTest : StringSpec({ routeDb = routeDatabase, routingObjectFactory = routingObjectFactory()) .get(), - configBlock(""" - config: + routingObjectDef(""" type: InterceptorPipeline config: pipeline: @@ -110,8 +108,7 @@ class HttpInterceptorPipelineTest : StringSpec({ routeDb = routeDatabase, routingObjectFactory = routingObjectFactory()) .get(), - configBlock(""" - config: + routingObjectDef(""" type: InterceptorPipeline config: pipeline: @@ -119,12 +116,13 @@ class HttpInterceptorPipelineTest : StringSpec({ - interceptor2 handler: name: MyHandler - type: BackendServiceProxy + type: StaticResponseHandler config: - backendProvider: backendProvider + status: 200 + content: hello """.trimIndent())) - val response = Mono.from(handler.handle(hwaRequest, null)).block() + val response = handler.handle(hwaRequest, null).toMono().block() response?.headers("X-Test-Header") shouldBe (listOf("B", "A")) } @@ -137,18 +135,18 @@ class HttpInterceptorPipelineTest : StringSpec({ routeDb = routeDatabase, routingObjectFactory = routingObjectFactory()) .get(), - configBlock(""" - config: + routingObjectDef(""" type: InterceptorPipeline config: handler: name: MyHandler - type: BackendServiceProxy + type: StaticResponseHandler config: - backendProvider: backendProvider + status: 200 + content: hello """.trimIndent())) - val response = Mono.from(handler.handle(hwaRequest, null)).block() + val response = handler.handle(hwaRequest, null).toMono().block() response?.status() shouldBe (OK) } @@ -160,14 +158,13 @@ class HttpInterceptorPipelineTest : StringSpec({ routeDb = routeDatabase, routingObjectFactory = routingObjectFactory()) .get(), - configBlock(""" - config: + routingObjectDef(""" type: InterceptorPipeline config: handler: referenceToAnotherRoutingObject """.trimIndent())) - val response = Mono.from(handler.handle(hwaRequest, null)).block() + val response = handler.handle(hwaRequest, null).toMono().block() response?.status() shouldBe NOT_FOUND } @@ -183,8 +180,7 @@ class HttpInterceptorPipelineTest : StringSpec({ routingObjectFactory = routingObjectFactory(), interceptorsFactory = BuiltinInterceptorsFactory(mapOf("Rewrite" to RewriteInterceptor.Factory())) ).get(), - configBlock(""" - config: + routingObjectDef(""" type: InterceptorPipeline config: pipeline: @@ -197,12 +193,13 @@ class HttpInterceptorPipelineTest : StringSpec({ - interceptor2 handler: name: MyHandler - type: BackendServiceProxy + type: StaticResponseHandler config: - backendProvider: backendProvider + status: 200 + content: hello """.trimIndent())) - val response = Mono.from(handler.handle(hwaRequest, null)).block() + val response = handler.handle(hwaRequest, null).toMono().block() response?.headers("X-Test-Header") shouldBe (listOf("B", "A")) } @@ -221,8 +218,7 @@ class HttpInterceptorPipelineTest : StringSpec({ routeDb = routeDatabase, routingObjectFactory = builtinsFactory) .get(), - configBlock(""" - config: + routingObjectDef(""" type: InterceptorPipeline config: handler: @@ -250,4 +246,4 @@ fun mockHandlerFactory(): HttpHandlerFactory { return handlerFactory } -fun routingObjectFactory() = RoutingObjectFactory(mapOf("BackendServiceProxy" to mockHandlerFactory()), mockk(), StyxObjectStore(), mockk(), mockk(), false) +fun routingObjectFactory() = RoutingObjectFactory(StyxObjectStore()) diff --git a/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/PathPrefixRouterTest.kt b/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/PathPrefixRouterTest.kt new file mode 100644 index 0000000000..4d23f98df1 --- /dev/null +++ b/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/PathPrefixRouterTest.kt @@ -0,0 +1,257 @@ +/* + Copyright (C) 2013-2019 Expedia Inc. + + Licensed 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 + + http://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.hotels.styx.routing.handlers + +import com.fasterxml.jackson.databind.JsonNode +import com.hotels.styx.api.HttpRequest +import com.hotels.styx.api.LiveHttpRequest.get +import com.hotels.styx.common.Pair.pair +import com.hotels.styx.config.schema.SchemaValidationException +import com.hotels.styx.infrastructure.configuration.yaml.YamlConfig +import com.hotels.styx.routing.RoutingObjectRecord +import com.hotels.styx.routing.config.HttpHandlerFactory +import com.hotels.styx.routing.config.RoutingObjectFactory +import com.hotels.styx.routing.config.RoutingObjectReference +import com.hotels.styx.routing.db.StyxObjectStore +import com.hotels.styx.routing.handle +import com.hotels.styx.routing.handlers.RouteRefLookup.RouteDbRefLookup +import com.hotels.styx.routing.routingObjectDef +import io.kotlintest.shouldBe +import io.kotlintest.shouldThrow +import io.kotlintest.specs.FeatureSpec +import io.mockk.mockk +import reactor.core.publisher.toMono +import java.nio.charset.StandardCharsets.UTF_8 +import java.util.Optional + +class PathPrefixRouterTest : FeatureSpec({ + + val factory = RoutingObjectFactory() + + val emptyObjectStore = StyxObjectStore() + val defaultFactoryContext = HttpHandlerFactory.Context( + mockk(), + emptyObjectStore, + RoutingObjectFactory(RouteDbRefLookup(emptyObjectStore)), + mockk(), + mockk(), + false) + + + feature("PathPrefixRouter") { + scenario("No path prefixes configured") { + val router = PathPrefixRouter(listOf()) + + router.route(get("/").build()) shouldBe Optional.empty() + } + + scenario("Root path") { + val rootHandler = factory.build(RoutingObjectReference("root")) + val router = PathPrefixRouter(listOf(pair("/", rootHandler))) + + router.route(get("/").build()) shouldBe Optional.of(rootHandler) + router.route(get("/foo/bar").build()) shouldBe Optional.of(rootHandler) + router.route(get("/foo/bar/").build()) shouldBe Optional.of(rootHandler) + router.route(get("/foo").build()) shouldBe Optional.of(rootHandler) + } + + scenario("Choice of most specific path") { + val fooFileHandler = factory.build(RoutingObjectReference("foo-file")) + val fooPathHandler = factory.build(RoutingObjectReference("foo-path")) + val fooBarFileHandler = factory.build(RoutingObjectReference("foo-bar-file")) + val fooBarPathHandler = factory.build(RoutingObjectReference("foo-bar-path")) + val fooBazFileHandler = factory.build(RoutingObjectReference("foo-baz-file")) + + val router = PathPrefixRouter(listOf( + pair("/foo", fooFileHandler), + pair("/foo/", fooPathHandler), + pair("/foo/bar", fooBarFileHandler), + pair("/foo/bar/", fooBarPathHandler), + pair("/foo/baz", fooBazFileHandler) + )) + + router.route(get("/").build()) shouldBe Optional.empty() + router.route(get("/foo").build()) shouldBe Optional.of(fooFileHandler) + router.route(get("/foo/x").build()) shouldBe Optional.of(fooPathHandler) + router.route(get("/foo/").build()) shouldBe Optional.of(fooPathHandler) + router.route(get("/foo/bar").build()) shouldBe Optional.of(fooBarFileHandler) + router.route(get("/foo/bar/x").build()) shouldBe Optional.of(fooBarPathHandler) + router.route(get("/foo/bar/").build()) shouldBe Optional.of(fooBarPathHandler) + router.route(get("/foo/baz/").build()) shouldBe Optional.of(fooBazFileHandler) + router.route(get("/foo/baz/y").build()) shouldBe Optional.of(fooBazFileHandler) + } + } + + feature("PathPrefixRouterFactory") { + + val rootHandler = factory.build(listOf(), routingObjectDef(""" + type: StaticResponseHandler + config: + status: 200 + content: root + """.trimIndent())) + + val fooHandler = factory.build(listOf(), routingObjectDef(""" + type: StaticResponseHandler + config: + status: 200 + content: foo + """.trimIndent())) + + scenario("Builds a PathPrefixRouter instance") { + val routingDef = routingObjectDef(""" + type: PathPrefixRouter + config: + routes: + - { prefix: /, destination: root } + - { prefix: /foo/, destination: foo } + """.trimIndent()) + + val objectStore = StyxObjectStore() + objectStore.insert("root", RoutingObjectRecord("X", routingDef.config(), rootHandler)) + objectStore.insert("foo", RoutingObjectRecord("X", routingDef.config(), fooHandler)) + + val factoryContext = HttpHandlerFactory.Context( + mockk(), + objectStore, + RoutingObjectFactory(RouteDbRefLookup(objectStore)), + mockk(), + mockk(), + false) + + val handler = PathPrefixRouter.Factory().build(listOf(), factoryContext, routingDef); + + handler.handle(HttpRequest.get("/x").build()) + .toMono() + .block() + ?.bodyAs(UTF_8) shouldBe "root" + + handler.handle(HttpRequest.get("/foo/").build()) + .toMono() + .block() + ?.bodyAs(UTF_8) shouldBe "foo" + } + + scenario("Supports inline routing object definitions") { + val routingDef = routingObjectDef(""" + type: PathPrefixRouter + config: + routes: + - prefix: /foo + destination: + type: StaticResponseHandler + config: + status: 200 + content: hello + """.trimIndent()) + + val handler = PathPrefixRouter.Factory().build(listOf(), defaultFactoryContext, routingDef); + + handler.handle(HttpRequest.get("/foo").build()) + .toMono() + .block() + ?.bodyAs(UTF_8) shouldBe "hello" + } + + scenario("Missing routes attribute") { + val routingDef = routingObjectDef(""" + type: PathPrefixRouter + config: + bar: 1 + """.trimIndent()) + + val e = shouldThrow { + PathPrefixRouter.Factory().build(listOf(), defaultFactoryContext, routingDef); + } + + e.message shouldBe "Routing object definition of type 'PathPrefixRouter', attribute='', is missing a mandatory 'routes' attribute." + } + } + + feature("Schema validation") { + val EXTENSIONS = { key: String -> RoutingObjectFactory.BUILTIN_HANDLER_SCHEMAS[key] } + + scenario("Accepts inlined routing object definitions") { + val jsonNode = YamlConfig(""" + routes: + - prefix: /foo + destination: + type: StaticResponseHandler + config: + status: 200 + content: hello + - prefix: /bar + destination: + type: StaticResponseHandler + config: + status: 200 + content: hello + """.trimIndent()).`as`(JsonNode::class.java) + + PathPrefixRouter.SCHEMA.validate(listOf(), jsonNode, jsonNode, EXTENSIONS) + } + + scenario("Accepts named routing object references") { + val jsonNode = YamlConfig(""" + routes: + - prefix: /foo + destination: foo + - prefix: /bar + destination: bar + """.trimIndent()).`as`(JsonNode::class.java) + + PathPrefixRouter.SCHEMA.validate(listOf(), jsonNode, jsonNode, EXTENSIONS) + } + + scenario("Accepts a mix of routing object references and inline definitions") { + val jsonNode = YamlConfig(""" + routes: + - prefix: /foo + destination: bar + - prefix: /bar + destination: + type: StaticResponseHandler + config: + status: 200 + content: hello + """.trimIndent()).`as`(JsonNode::class.java) + + PathPrefixRouter.SCHEMA.validate(listOf(), jsonNode, jsonNode, EXTENSIONS) + } + + scenario("Accepts an empty routes list") { + val jsonNode = YamlConfig(""" + routes: [] + """.trimIndent()).`as`(JsonNode::class.java) + + PathPrefixRouter.SCHEMA.validate(listOf(), jsonNode, jsonNode, EXTENSIONS) + } + + scenario("Rejects unrelated attributes") { + val jsonNode = YamlConfig(""" + routes: [] + notAllowed: here + """.trimIndent()).`as`(JsonNode::class.java) + + val e = shouldThrow { + PathPrefixRouter.SCHEMA.validate(listOf(), jsonNode, jsonNode, EXTENSIONS) + } + + e.message shouldBe "Unexpected field: 'notAllowed'" + } + } +}) + diff --git a/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/ProxyToBackendTest.kt b/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/ProxyToBackendTest.kt index 9f0499b764..6e6c7479ee 100644 --- a/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/ProxyToBackendTest.kt +++ b/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/ProxyToBackendTest.kt @@ -23,18 +23,18 @@ import com.hotels.styx.api.LiveHttpResponse import com.hotels.styx.client.BackendServiceClient import com.hotels.styx.proxy.BackendServiceClientFactory import com.hotels.styx.routing.RoutingContext -import com.hotels.styx.routing.configBlock +import com.hotels.styx.routing.routingObjectDef import com.hotels.styx.server.HttpInterceptorContext import io.kotlintest.shouldBe import io.kotlintest.shouldThrow import io.kotlintest.specs.StringSpec import reactor.core.publisher.Mono +import reactor.core.publisher.toMono class ProxyToBackendTest : StringSpec({ val environment = Environment.Builder().build() - val config = configBlock(""" - config: + val config = routingObjectDef(""" name: ProxyToBackend type: ProxyToBackend config: @@ -54,13 +54,12 @@ class ProxyToBackendTest : StringSpec({ "builds ProxyToBackend handler" { val handler = ProxyToBackend.Factory.build(listOf(), context, config, clientFactory()); - val response = Mono.from(handler.handle(LiveHttpRequest.get("/foo").build(), HttpInterceptorContext.create())).block() + val response = handler.handle(LiveHttpRequest.get("/foo").build(), HttpInterceptorContext.create()).toMono().block() response?.status() shouldBe (OK) } "throws for missing mandatory 'backend' attribute" { - val config = configBlock(""" - config: + val config = routingObjectDef(""" name: myProxy type: ProxyToBackend config: @@ -75,8 +74,7 @@ class ProxyToBackendTest : StringSpec({ } "throws for a missing mandatory backend.origins attribute" { - val config = configBlock(""" - config: + val config = routingObjectDef(""" name: ProxyToBackend type: ProxyToBackend config: diff --git a/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/RouteRefLookupTest.kt b/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/RouteRefLookupTest.kt new file mode 100644 index 0000000000..b16d82b46c --- /dev/null +++ b/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/RouteRefLookupTest.kt @@ -0,0 +1,88 @@ +/* + Copyright (C) 2013-2019 Expedia Inc. + + Licensed 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 + + http://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.hotels.styx.routing.handlers + +import com.hotels.styx.api.Buffer +import com.hotels.styx.api.ByteStream +import com.hotels.styx.api.HttpHandler +import com.hotels.styx.api.HttpRequest.get +import com.hotels.styx.api.HttpResponseStatus.NOT_FOUND +import com.hotels.styx.api.LiveHttpRequest +import com.hotels.styx.routing.RoutingObjectRecord +import com.hotels.styx.routing.config.RoutingObjectReference +import com.hotels.styx.routing.db.StyxObjectStore +import com.hotels.styx.routing.handle +import com.hotels.styx.routing.handlers.RouteRefLookup.RouteDbRefLookup +import com.hotels.styx.server.HttpInterceptorContext +import io.kotlintest.shouldBe +import io.kotlintest.specs.StringSpec +import io.mockk.every +import io.mockk.mockk +import reactor.core.publisher.Flux +import reactor.core.publisher.toMono +import reactor.test.publisher.PublisherProbe +import java.nio.charset.StandardCharsets.UTF_8 +import java.util.Optional + +class RouteRefLookupTest : StringSpec({ + "Retrieves handler from route database" { + val handler = mockk() + + val routeDb = mockk>() + every { routeDb.get(any()) } returns Optional.of(RoutingObjectRecord("StaticResponseHandler", mockk(), handler)) + + RouteDbRefLookup(routeDb).apply(RoutingObjectReference("handler1")) shouldBe handler + } + + "Returns error handler when route object is not found" { + val routeDb = mockk>() + every { routeDb.get(any()) } returns Optional.empty() + + val response = RouteDbRefLookup(routeDb).apply(RoutingObjectReference("handler1")) + .handle(get("/").build()) + .toMono() + .block() + + response?.status() shouldBe NOT_FOUND + response?.bodyAs(UTF_8) shouldBe "Not found: handler1" + } + + "Error handler consumes the request body" { + val routeDb = mockk>() + every { routeDb.get(any()) } returns Optional.empty() + + val probe = PublisherProbe.of( + Flux.just( + Buffer("aaa", UTF_8), + Buffer("bbb", UTF_8))) + + val response = RouteDbRefLookup(routeDb).apply(RoutingObjectReference("handler1")) + .handle(LiveHttpRequest.post("/") + .body(ByteStream(probe.flux())) + .build(), HttpInterceptorContext.create()) + .toMono() + .flatMap { it.aggregate(1000).toMono() } + .block() + + response?.status() shouldBe NOT_FOUND + response?.bodyAs(UTF_8) shouldBe "Not found: handler1" + + probe.wasSubscribed() shouldBe true + probe.wasRequested() shouldBe true + probe.wasCancelled() shouldBe false + } + +}) \ No newline at end of file diff --git a/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/StaticResponseHandlerTest.kt b/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/StaticResponseHandlerTest.kt index 5fda7a0051..973b7ccbe6 100644 --- a/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/StaticResponseHandlerTest.kt +++ b/components/proxy/src/test/kotlin/com/hotels/styx/routing/handlers/StaticResponseHandlerTest.kt @@ -18,18 +18,17 @@ package com.hotels.styx.routing.handlers import com.hotels.styx.api.HttpResponseStatus.CREATED import com.hotels.styx.api.LiveHttpRequest import com.hotels.styx.routing.RoutingContext -import com.hotels.styx.routing.configBlock +import com.hotels.styx.routing.routingObjectDef import com.hotels.styx.server.HttpInterceptorContext import io.kotlintest.shouldBe import io.kotlintest.specs.StringSpec -import reactor.core.publisher.Mono +import reactor.core.publisher.toMono class StaticResponseHandlerTest: StringSpec({ val context = RoutingContext().get() - val config = configBlock(""" - config: + val config = routingObjectDef(""" name: proxy-and-log-to-https type: StaticResponseHandler config: @@ -39,7 +38,7 @@ class StaticResponseHandlerTest: StringSpec({ "builds static response handler" { val handler = StaticResponseHandler.Factory().build(listOf(), context, config) - val response = Mono.from(handler.handle(LiveHttpRequest.get("/foo").build(), HttpInterceptorContext.create())).block() + val response = handler.handle(LiveHttpRequest.get("/foo").build(), HttpInterceptorContext.create()).toMono().block() response?.status() shouldBe (CREATED) } diff --git a/components/proxy/src/test/kotlin/com/hotels/styx/routing/interceptors/RewriteInterceptorTest.kt b/components/proxy/src/test/kotlin/com/hotels/styx/routing/interceptors/RewriteInterceptorTest.kt index da5ba3b029..7c025648df 100644 --- a/components/proxy/src/test/kotlin/com/hotels/styx/routing/interceptors/RewriteInterceptorTest.kt +++ b/components/proxy/src/test/kotlin/com/hotels/styx/routing/interceptors/RewriteInterceptorTest.kt @@ -21,16 +21,15 @@ import com.hotels.styx.api.HttpResponseStatus.OK import com.hotels.styx.api.LiveHttpRequest import com.hotels.styx.api.LiveHttpResponse import com.hotels.styx.api.LiveHttpResponse.response -import com.hotels.styx.routing.configBlock +import com.hotels.styx.routing.routingObjectDef import io.kotlintest.shouldBe import io.kotlintest.specs.StringSpec -import reactor.core.publisher.Mono +import reactor.core.publisher.toMono class RewriteInterceptorTest : StringSpec({ "performs replacement" { - val config = configBlock(""" - config: + val config = routingObjectDef(""" name: rewrite type: Rewrite config: @@ -43,14 +42,13 @@ class RewriteInterceptorTest : StringSpec({ val interceptor = RewriteInterceptor.Factory().build(config) val capturingChain = CapturingChain() - val response = Mono.from(interceptor.intercept(LiveHttpRequest.get("/foo").build(), capturingChain)).block() + interceptor.intercept(LiveHttpRequest.get("/foo").build(), capturingChain).toMono().block() capturingChain.request()?.path() shouldBe ("/app/foo") } "Empty config block does nothing" { - val config = configBlock(""" - config: + val config = routingObjectDef(""" name: rewrite type: Rewrite config: @@ -59,7 +57,7 @@ class RewriteInterceptorTest : StringSpec({ val interceptor = RewriteInterceptor.Factory().build(config) val capturingChain = CapturingChain() - val response = Mono.from(interceptor.intercept(LiveHttpRequest.get("/foo").build(), capturingChain)).block() + interceptor.intercept(LiveHttpRequest.get("/foo").build(), capturingChain).toMono().block() capturingChain.request()?.path() shouldBe ("/foo") } diff --git a/components/server/src/main/java/com/hotels/styx/server/PathPrefixRouter.java b/components/server/src/main/java/com/hotels/styx/server/PathPrefixRouter.java deleted file mode 100644 index 2d60354a77..0000000000 --- a/components/server/src/main/java/com/hotels/styx/server/PathPrefixRouter.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - Copyright (C) 2013-2018 Expedia Inc. - - Licensed 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 - - http://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.hotels.styx.server; - -import com.hotels.styx.api.HttpHandler; -import com.hotels.styx.api.HttpInterceptor; -import com.hotels.styx.api.LiveHttpRequest; - -import java.util.Optional; - -/** - * Makes a routing decision based on longest matching URL path prefix. - */ -public class PathPrefixRouter implements HttpRouter { - private final PathTrie routes = new PathTrie<>(); - - @Override - public Optional route(LiveHttpRequest request, HttpInterceptor.Context ignore) { - return routes.get(request.path()); - } - - public PathPrefixRouter add(String path, HttpHandler httpHandler) { - routes.put(path, httpHandler); - return this; - } - -} diff --git a/system-tests/ft-suite/src/test/kotlin/com/hotels/styx/routing/PathPrefixRoutingSpec.kt b/system-tests/ft-suite/src/test/kotlin/com/hotels/styx/routing/PathPrefixRoutingSpec.kt new file mode 100644 index 0000000000..28b5e7ed1c --- /dev/null +++ b/system-tests/ft-suite/src/test/kotlin/com/hotels/styx/routing/PathPrefixRoutingSpec.kt @@ -0,0 +1,120 @@ +/* + Copyright (C) 2013-2019 Expedia Inc. + + Licensed 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 + + http://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.hotels.styx.routing + +import com.hotels.styx.StyxConfig +import com.hotels.styx.StyxServer +import com.hotels.styx.api.HttpHeaderNames.HOST +import com.hotels.styx.api.HttpRequest.get +import com.hotels.styx.client.StyxHttpClient +import com.hotels.styx.startup.StyxServerComponents +import com.hotels.styx.support.ResourcePaths.fixturesHome +import io.kotlintest.Spec +import io.kotlintest.shouldBe +import io.kotlintest.specs.StringSpec +import reactor.core.publisher.toMono +import java.nio.charset.StandardCharsets.UTF_8 +import java.util.concurrent.TimeUnit.MILLISECONDS + +class PathPrefixRoutingSpec : StringSpec() { + + init { + "Routes to the best match" { + val proxyHost = "${styxServer.proxyHttpAddress().hostName}:${styxServer.proxyHttpAddress().port}" + + client.send(get("/a/path") + .header(HOST, proxyHost) + .build()) + .toMono() + .block() + ?.bodyAs(UTF_8) shouldBe "I'm default" + + client.send(get("/database/a/path") + .header(HOST, proxyHost) + .build()) + .toMono() + .block() + ?.bodyAs(UTF_8) shouldBe "I'm database" + } + } + + val originsOk = fixturesHome(ConditionRoutingSpec::class.java, "/conf/origins/origins-correct.yml") + val yamlText = """ + proxy: + connectors: + http: + port: 0 + + https: + port: 0 + sslProvider: JDK + sessionTimeoutMillis: 300000 + sessionCacheSize: 20000 + + services: + factories: + backendServiceRegistry: + class: "com.hotels.styx.proxy.backends.file.FileBackedBackendServicesRegistry${'$'}Factory" + config: {originsFile: "$originsOk"} + + admin: + connectors: + http: + port: 0 + + routingObjects: + root: + type: PathPrefixRouter + config: + routes: + - prefix: / + destination: default + - prefix: /database + destination: database + + default: + type: StaticResponseHandler + config: + status: 200 + content: "I'm default" + + database: + type: StaticResponseHandler + config: + status: 200 + content: "I'm database" + + httpPipeline: root + """.trimIndent() + + val client: StyxHttpClient = StyxHttpClient.Builder() + .threadName("functional-test-client") + .connectTimeout(1000, MILLISECONDS) + .maxHeaderSize(2 * 8192) + .build() + + val styxServer = StyxServer(StyxServerComponents.Builder() + .styxConfig(StyxConfig.fromYaml(yamlText)) + .build()) + + override fun beforeSpec(spec: Spec) { + styxServer.startAsync().awaitRunning() + } + + override fun afterSpec(spec: Spec) { + styxServer.stopAsync().awaitTerminated() + } +}