diff --git a/adapter/awslambda/build.gradle b/adapter/awslambda/build.gradle index 2d3e78ce0..c9650045d 100644 --- a/adapter/awslambda/build.gradle +++ b/adapter/awslambda/build.gradle @@ -2,7 +2,6 @@ apply plugin: 'java-library' apply plugin: 'kotlin' ext { - version_junit_jupiter = '5.10.0' version_lambda_logger = '1.5.1' } diff --git a/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/ServerV1.kt b/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/ServerV1.kt index a8e16de6d..743224c7a 100644 --- a/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/ServerV1.kt +++ b/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/ServerV1.kt @@ -59,7 +59,7 @@ import io.gatehill.imposter.util.HttpUtil */ class ServerV1( responseService: ResponseService, - router: HttpRouter, + private val router: HttpRouter, ) : LambdaServer(responseService, router) { init { @@ -74,7 +74,7 @@ class ServerV1( return HttpUtil.readAcceptedContentTypes(event.headers["Accept"]).contains(HttpUtil.CONTENT_TYPE_HTML) } - override fun buildRequest(event: APIGatewayProxyRequestEvent, route: HttpRoute?) = LambdaHttpRequestV1(event, route) + override fun buildRequest(event: APIGatewayProxyRequestEvent, route: HttpRoute?) = LambdaHttpRequestV1(event, router, route) override fun buildResponse(response: LambdaHttpResponse) = APIGatewayProxyResponseEvent().apply { // read status again in case modified by error handler diff --git a/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/ServerV2.kt b/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/ServerV2.kt index cd63c6b92..27b772c7e 100644 --- a/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/ServerV2.kt +++ b/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/ServerV2.kt @@ -59,7 +59,7 @@ import io.gatehill.imposter.util.HttpUtil */ class ServerV2( responseService: ResponseService, - router: HttpRouter, + private val router: HttpRouter, ) : LambdaServer(responseService, router) { init { @@ -74,7 +74,7 @@ class ServerV2( return HttpUtil.readAcceptedContentTypes(event.headers["Accept"]).contains(HttpUtil.CONTENT_TYPE_HTML) } - override fun buildRequest(event: APIGatewayV2HTTPEvent, route: HttpRoute?) = LambdaHttpRequestV2(event, route) + override fun buildRequest(event: APIGatewayV2HTTPEvent, route: HttpRoute?) = LambdaHttpRequestV2(event, router, route) override fun buildResponse(response: LambdaHttpResponse) = APIGatewayV2HTTPResponse().apply { // read status again in case modified by error handler diff --git a/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/model/LambdaHttpRequestV1.kt b/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/model/LambdaHttpRequestV1.kt index c70c09b8d..b244f9330 100644 --- a/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/model/LambdaHttpRequestV1.kt +++ b/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/model/LambdaHttpRequestV1.kt @@ -49,6 +49,8 @@ import io.gatehill.imposter.awslambda.util.FormParserUtil import io.gatehill.imposter.http.HttpMethod import io.gatehill.imposter.http.HttpRequest import io.gatehill.imposter.http.HttpRoute +import io.gatehill.imposter.http.HttpRouter +import io.gatehill.imposter.http.util.PathNormaliser import io.gatehill.imposter.script.LowercaseKeysMap import io.vertx.core.buffer.Buffer import io.vertx.core.json.JsonObject @@ -58,6 +60,7 @@ import io.vertx.core.json.JsonObject */ class LambdaHttpRequestV1( private val event: APIGatewayProxyRequestEvent, + private val router: HttpRouter, private val currentRoute: HttpRoute?, ) : HttpRequest { private val baseUrl: String @@ -91,10 +94,10 @@ class LambdaHttpRequestV1( } override val pathParams: Map - get() = pathParameters + get() = PathNormaliser.denormaliseParams(router.normalisedParams, pathParameters) override fun getPathParam(paramName: String): String? { - return pathParameters[paramName] + return pathParameters[PathNormaliser.getNormalisedParamName(router.normalisedParams, paramName)] } override val queryParams: Map diff --git a/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/model/LambdaHttpRequestV2.kt b/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/model/LambdaHttpRequestV2.kt index 254e13897..a42c9b410 100644 --- a/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/model/LambdaHttpRequestV2.kt +++ b/adapter/awslambda/src/main/java/io/gatehill/imposter/awslambda/impl/model/LambdaHttpRequestV2.kt @@ -49,6 +49,8 @@ import io.gatehill.imposter.awslambda.util.FormParserUtil import io.gatehill.imposter.http.HttpMethod import io.gatehill.imposter.http.HttpRequest import io.gatehill.imposter.http.HttpRoute +import io.gatehill.imposter.http.HttpRouter +import io.gatehill.imposter.http.util.PathNormaliser import io.gatehill.imposter.script.LowercaseKeysMap import io.vertx.core.buffer.Buffer import io.vertx.core.json.JsonObject @@ -58,6 +60,7 @@ import io.vertx.core.json.JsonObject */ class LambdaHttpRequestV2( private val event: APIGatewayV2HTTPEvent, + private val router: HttpRouter, private val currentRoute: HttpRoute?, ) : HttpRequest { private val baseUrl: String @@ -91,10 +94,10 @@ class LambdaHttpRequestV2( } override val pathParams: Map - get() = pathParameters + get() = PathNormaliser.denormaliseParams(router.normalisedParams, pathParameters) override fun getPathParam(paramName: String): String? { - return pathParameters[paramName] + return pathParameters[PathNormaliser.getNormalisedParamName(router.normalisedParams, paramName)] } override val queryParams: Map diff --git a/adapter/vertxweb/build.gradle b/adapter/vertxweb/build.gradle index c0cf13e89..857c97073 100644 --- a/adapter/vertxweb/build.gradle +++ b/adapter/vertxweb/build.gradle @@ -2,10 +2,6 @@ apply plugin: 'java-library' apply plugin: 'kotlin' apply plugin: 'maven-publish' -ext { - version_junit_jupiter = '5.10.0' -} - compileJava { sourceCompatibility = JavaVersion.VERSION_11 } diff --git a/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/VertxWebServerFactoryImpl.kt b/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/VertxWebServerFactoryImpl.kt index a4111745b..b743d62a6 100644 --- a/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/VertxWebServerFactoryImpl.kt +++ b/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/VertxWebServerFactoryImpl.kt @@ -134,22 +134,21 @@ class VertxWebServerFactoryImpl : ServerFactory { } private fun convertRouterToVertx(router: HttpRouter) = Router.router(router.vertx).also { vertxRouter -> - val normalisedParams = mutableMapOf() router.routes.forEach { httpRoute -> val vertxRoute = httpRoute.regex?.let { regex -> httpRoute.method?.let { method -> vertxRouter.routeWithRegex(method.convertMethodToVertx(), regex) } ?: vertxRouter.routeWithRegex(regex) } ?: httpRoute.path?.let { path -> - val normalisedPath = VertxResourceUtil.normalisePath(normalisedParams, path) - httpRoute.method?.let { method -> vertxRouter.route(method.convertMethodToVertx(), normalisedPath) } - ?: vertxRouter.route(normalisedPath) + val convertedPath = VertxResourceUtil.convertPath(path) + httpRoute.method?.let { method -> vertxRouter.route(method.convertMethodToVertx(), convertedPath) } + ?: vertxRouter.route(convertedPath) } ?: vertxRouter.route() val handler = httpRoute.handler ?: throw IllegalStateException("No route handler set for: $httpRoute") vertxRoute.handler { rc -> - val exchange = VertxHttpExchange(router, normalisedParams, rc, httpRoute) + val exchange = VertxHttpExchange(router, httpRoute, rc) // don't block the event loop handler(exchange).handle { _, t -> @@ -161,7 +160,7 @@ class VertxWebServerFactoryImpl : ServerFactory { router.errorHandlers.forEach { (statusCode, errorHandler) -> vertxRouter.errorHandler(statusCode) { rc -> - errorHandler(VertxHttpExchange(router, normalisedParams, rc, null)) + errorHandler(VertxHttpExchange(router, null, rc)) } } } diff --git a/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/impl/VertxHttpExchange.kt b/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/impl/VertxHttpExchange.kt index 2bd0e2e03..0e3cd48bd 100644 --- a/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/impl/VertxHttpExchange.kt +++ b/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/impl/VertxHttpExchange.kt @@ -56,14 +56,13 @@ import io.vertx.ext.web.impl.ParsableMIMEValue */ class VertxHttpExchange( private val router: HttpRouter, - normalisedParams: Map, - internal val routingContext: RoutingContext, override val currentRoute: HttpRoute?, + internal val routingContext: RoutingContext, ) : HttpExchange { override var phase = ExchangePhase.REQUEST_RECEIVED override val request: HttpRequest by lazy { - VertxHttpRequest(normalisedParams, routingContext) + VertxHttpRequest(router, routingContext) } override val response: HttpResponse by lazy { diff --git a/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/impl/VertxHttpRequest.kt b/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/impl/VertxHttpRequest.kt index 188178582..25c32f607 100644 --- a/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/impl/VertxHttpRequest.kt +++ b/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/impl/VertxHttpRequest.kt @@ -44,7 +44,8 @@ package io.gatehill.imposter.server.vertxweb.impl import io.gatehill.imposter.http.HttpMethod import io.gatehill.imposter.http.HttpRequest -import io.gatehill.imposter.server.vertxweb.util.VertxResourceUtil +import io.gatehill.imposter.http.HttpRouter +import io.gatehill.imposter.http.util.PathNormaliser import io.gatehill.imposter.server.vertxweb.util.VertxResourceUtil.convertMethodFromVertx import io.gatehill.imposter.util.CollectionUtil import io.vertx.core.buffer.Buffer @@ -55,7 +56,7 @@ import io.vertx.ext.web.RoutingContext * @author Pete Cornish */ class VertxHttpRequest( - private val normalisedParams: Map, + private val router: HttpRouter, private val routingContext: RoutingContext, ) : HttpRequest { private val vertxRequest = routingContext.request() @@ -80,10 +81,10 @@ class VertxHttpRequest( } override val pathParams: Map - get() = VertxResourceUtil.denormaliseParams(normalisedParams, routingContext.pathParams()) + get() = PathNormaliser.denormaliseParams(router.normalisedParams, routingContext.pathParams()) override fun getPathParam(paramName: String): String? { - return routingContext.pathParam(VertxResourceUtil.getNormalisedParamName(normalisedParams, paramName)) + return routingContext.pathParam(PathNormaliser.getNormalisedParamName(router.normalisedParams, paramName)) } override val queryParams: Map diff --git a/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/util/VertxResourceUtil.kt b/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/util/VertxResourceUtil.kt index 030b77e9a..818edd498 100644 --- a/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/util/VertxResourceUtil.kt +++ b/adapter/vertxweb/src/main/java/io/gatehill/imposter/server/vertxweb/util/VertxResourceUtil.kt @@ -42,32 +42,16 @@ */ package io.gatehill.imposter.server.vertxweb.util -import com.google.common.base.Strings import com.google.common.collect.BiMap import com.google.common.collect.HashBiMap import io.gatehill.imposter.config.util.EnvVars import io.gatehill.imposter.http.HttpMethod import io.gatehill.imposter.http.HttpRoute -import org.apache.logging.log4j.LogManager -import java.util.UUID /** * @author Pete Cornish */ object VertxResourceUtil { - private val LOGGER = LogManager.getLogger(VertxResourceUtil::class.java) - - /** - * Vert.x documentation says: - * > The placeholders consist of : followed by the parameter name. - * > Parameter names consist of any alphabetic character, numeric character or underscore. - * - * See: https://vertx.io/docs/vertx-web/java/#_capturing_path_parameters - * - * This regex pattern does not include the colon prefix. - */ - private val VERTX_PATH_PARAM_NAME = Regex("[a-zA-Z0-9_]+") - private val METHODS: BiMap = HashBiMap.create() /** @@ -100,10 +84,7 @@ object VertxResourceUtil { METHODS.inverse()[this] ?: throw UnsupportedOperationException("Unknown method: $this") /** - * Normalises path parameters to Vert.x format. - * - * If a path parameter name does not match the Vert.x format, it is replaced with a UUID, - * and a mapping is added to the `normalisedParams` map. + * Converts path parameters to Vert.x format. * * For example: * ``` @@ -113,62 +94,27 @@ object VertxResourceUtil { * ``` * /:pathParam/notParam * ``` - * - * A path parameter name that does not match the Vert.x format, such as: - * ``` - * /{param-with-dashes} - * ``` - * will be converted to: - * ``` - * /:123e4567e89b12d3a4564266141740000 - * ``` - * and the mapping `param-with-dashes -> 123e4567e89b12d3a4564266141740000` - * will be added to the `normalisedParams` map. - * - * @param normalisedParams a map to store the normalised parameter names * @param rawPath the path to normalise */ - fun normalisePath(normalisedParams: MutableMap, rawPath: String): String { + fun convertPath(rawPath: String): String { var path = rawPath - if (!Strings.isNullOrEmpty(path)) { + if (path.isNotEmpty()) { if (escapeColonsInPath) { path = path.replace(":", "%3A") } + // convert to colon format var matchFound: Boolean do { val matcher = HttpRoute.PATH_PARAM_PLACEHOLDER.matcher(path) matchFound = matcher.find() if (matchFound) { - val finalParamName = matcher.group(1).let { paramName -> - if (paramName.matches(VERTX_PATH_PARAM_NAME)) { - paramName - } else { - val existingMapping = normalisedParams.entries.find { it.value == paramName } - if (null != existingMapping) { - return@let existingMapping.key - } - val normalisedName = UUID.randomUUID().toString().replace("-", "") - normalisedParams[normalisedName] = paramName - normalisedName - } - } - path = matcher.replaceFirst(":$finalParamName") + val paramName = matcher.group(1) + path = matcher.replaceFirst(":$paramName") } } while (matchFound) } - return path - } - - fun getNormalisedParamName(normalisedParams: Map, originalParamName: String): String { - if (originalParamName.matches(VERTX_PATH_PARAM_NAME)) { - return originalParamName - } - return normalisedParams.entries.find { it.value == originalParamName }?.key ?: originalParamName - } - fun denormaliseParams(normalisedParams: Map, vertxParams: Map): Map { - // if it's not in the map it doesn't need to be denormalised - return vertxParams.mapKeys { normalisedParams[it.key] ?: it.key } + return path } } diff --git a/adapter/vertxweb/src/test/java/io/gatehill/imposter/server/vertxweb/util/VertxResourceUtilTest.kt b/adapter/vertxweb/src/test/java/io/gatehill/imposter/server/vertxweb/util/VertxResourceUtilTest.kt index bcd294b2c..e0dd03428 100644 --- a/adapter/vertxweb/src/test/java/io/gatehill/imposter/server/vertxweb/util/VertxResourceUtilTest.kt +++ b/adapter/vertxweb/src/test/java/io/gatehill/imposter/server/vertxweb/util/VertxResourceUtilTest.kt @@ -44,9 +44,6 @@ package io.gatehill.imposter.server.vertxweb.util import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNotEquals -import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Test /** @@ -57,57 +54,19 @@ import org.junit.jupiter.api.Test class VertxResourceUtilTest { @Test fun `should convert path to Vertx format`() { - val normalisedParams = mutableMapOf() - val result = VertxResourceUtil.normalisePath(normalisedParams, "/{pathParam}/notParam") - - assertEquals(0, normalisedParams.size) + val result = VertxResourceUtil.convertPath("/{pathParam}/notParam") assertEquals("/:pathParam/notParam", result) } @Test fun `should handle param and plain string`() { - val normalisedParams = mutableMapOf() - val result = VertxResourceUtil.normalisePath(normalisedParams, "/{firstParam}.notParam") - - assertEquals(0, normalisedParams.size) + val result = VertxResourceUtil.convertPath("/{firstParam}.notParam") assertEquals("/:firstParam.notParam", result) } @Test - fun `should normalise path`() { - val normalisedParams = mutableMapOf() - val result = VertxResourceUtil.normalisePath(normalisedParams, "/{firstParam}/{second-param}/notParam") - - assertEquals(1, normalisedParams.size) - assertFalse(normalisedParams.containsKey("firstParam")) - - val secondParam = normalisedParams.entries.first() - assertNotNull(secondParam) - assertEquals("second-param", secondParam.value) - assertNotEquals("second-param", secondParam.key) - assertEquals("/:firstParam/:${secondParam.key}/notParam", result) - } - - @Test - fun `should get normalised param name`() { - val normalisedParams = mapOf( - "abcdef1234567890" to "kebab-param" - ) - assertEquals("abcdef1234567890", VertxResourceUtil.getNormalisedParamName(normalisedParams, "kebab-param")) - assertEquals("normalParam", VertxResourceUtil.getNormalisedParamName(normalisedParams, "normalParam")) - } - - @Test - fun `should denormalise params`() { - val normalisedParams = mapOf( - "abcdef1234567890" to "kebab-param" - ) - val vertxParams = mapOf( - "abcdef1234567890" to "param value", - "content-type" to "application/json" - ) - val result = VertxResourceUtil.denormaliseParams(normalisedParams, vertxParams) - assertEquals("param value", result["kebab-param"]) - assertEquals("application/json", result["content-type"]) + fun `should convert path with multiple path params`() { + val result = VertxResourceUtil.convertPath("/{firstParam}/{secondParam}/notParam") + assertEquals("/:firstParam/:secondParam/notParam", result) } } diff --git a/build.gradle b/build.gradle index ab96ad542..0ce55235e 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,7 @@ ext { version_jackson_databind = '2.13.4.2' version_jaxb_api = '2.3.1' version_junit = '4.13.2' + version_junit_jupiter = '5.10.0' version_log4j = '2.18.0' version_micrometer = '1.9.4' version_mockito = '5.2.0' diff --git a/core/engine/src/main/java/io/gatehill/imposter/http/AbstractResourceMatcher.kt b/core/engine/src/main/java/io/gatehill/imposter/http/AbstractResourceMatcher.kt index aff27ade8..2f4034368 100644 --- a/core/engine/src/main/java/io/gatehill/imposter/http/AbstractResourceMatcher.kt +++ b/core/engine/src/main/java/io/gatehill/imposter/http/AbstractResourceMatcher.kt @@ -45,6 +45,7 @@ package io.gatehill.imposter.http import com.google.common.base.Strings.isNullOrEmpty import com.google.common.cache.CacheBuilder import io.gatehill.imposter.config.ResolvedResourceConfig +import io.gatehill.imposter.http.util.PathNormaliser import io.gatehill.imposter.plugin.config.PluginConfig import io.gatehill.imposter.plugin.config.resource.BasicResourceConfig import io.gatehill.imposter.plugin.config.resource.conditional.MatchOperator @@ -164,16 +165,18 @@ abstract class AbstractResourceMatcher : ResourceMatcher { ): ResourceMatchResult { val matchDescription = "path" - // note: path template can be null when a regex route is used - val routePathTemplate = httpExchange.currentRoute?.path - val pathMatch = resourceConfig.path?.let { resourceConfigPath -> if (resourceConfigPath.endsWith("*") && request.path.startsWith(resourceConfigPath.substring(0, resourceConfigPath.length - 1))) { return@let ResourceMatchResult.wildcardMatch(matchDescription) - } else if (request.path == resourceConfigPath || routePathTemplate?.let { it == resourceConfigPath } == true) { + } else if (request.path == resourceConfigPath) { return@let ResourceMatchResult.exactMatch(matchDescription) } else { - return@let ResourceMatchResult.notMatched(matchDescription) + val currentRoute = httpExchange.currentRoute + if (null != currentRoute && isPathTemplateMatch(currentRoute, resourceConfigPath)) { + return@let ResourceMatchResult.exactMatch(matchDescription) + } else { + return@let ResourceMatchResult.notMatched(matchDescription) + } } // path is un-set } ?: ResourceMatchResult.noConfig(matchDescription) @@ -181,6 +184,17 @@ abstract class AbstractResourceMatcher : ResourceMatcher { return pathMatch } + private fun isPathTemplateMatch(currentRoute: HttpRoute, resourceConfigPath: String): Boolean { + val normalisedParams = currentRoute.router.normalisedParams.toMutableMap() + val normalisedResourcePath = PathNormaliser.normalisePath( + normalisedParams, + resourceConfigPath, + ) + // note: path template can be null when a regex route is used + val templateMatch = currentRoute.path == normalisedResourcePath + return templateMatch + } + /** * Match the request body against the supplied configuration. * diff --git a/core/http/build.gradle b/core/http/build.gradle index cf1068841..e30387ed5 100644 --- a/core/http/build.gradle +++ b/core/http/build.gradle @@ -10,6 +10,13 @@ dependencies { implementation "io.vertx:vertx-core:$version_vertx" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "com.fasterxml.jackson.core:jackson-databind:$version_jackson_databind" + + testImplementation "org.junit.jupiter:junit-jupiter-api:$version_junit_jupiter" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$version_junit_jupiter" + testImplementation "org.mockito:mockito-core:$version_mockito" + testImplementation "org.mockito.kotlin:mockito-kotlin:$version_mockito_kotlin" + testRuntimeOnly "org.apache.logging.log4j:log4j-core:$version_log4j" + testRuntimeOnly "org.apache.logging.log4j:log4j-slf4j-impl:$version_log4j" } task sourcesJar(type: Jar, dependsOn: classes) { @@ -57,3 +64,7 @@ compileTestKotlin { freeCompilerArgs = ["-Xjvm-default=all"] } } + +test { + useJUnitPlatform() +} diff --git a/core/http/src/main/java/io/gatehill/imposter/http/HttpRoute.kt b/core/http/src/main/java/io/gatehill/imposter/http/HttpRoute.kt index 324007940..bf84c6c11 100644 --- a/core/http/src/main/java/io/gatehill/imposter/http/HttpRoute.kt +++ b/core/http/src/main/java/io/gatehill/imposter/http/HttpRoute.kt @@ -49,9 +49,10 @@ import java.util.regex.Pattern * @author Pete Cornish */ data class HttpRoute( + val router: HttpRouter, val path: String? = null, val regex: String? = null, - val method: HttpMethod? = null + val method: HttpMethod? = null, ) { val hasTrailingWildcard = path?.endsWith('*') ?: false var handler: HttpExchangeFutureHandler? = null diff --git a/core/http/src/main/java/io/gatehill/imposter/http/HttpRouter.kt b/core/http/src/main/java/io/gatehill/imposter/http/HttpRouter.kt index df3fa97cd..78b73c02c 100644 --- a/core/http/src/main/java/io/gatehill/imposter/http/HttpRouter.kt +++ b/core/http/src/main/java/io/gatehill/imposter/http/HttpRouter.kt @@ -42,6 +42,7 @@ */ package io.gatehill.imposter.http +import io.gatehill.imposter.http.util.PathNormaliser import io.vertx.core.Vertx import java.util.concurrent.CompletableFuture @@ -52,21 +53,27 @@ class HttpRouter(val vertx: Vertx) { val routes = mutableListOf() val errorHandlers = mutableMapOf() private val beforeEndHandlers = mutableListOf() + private val _normalisedParams = mutableMapOf() + + val normalisedParams: Map + get() = _normalisedParams fun route(): HttpRoute { - return HttpRoute().also(::addOrReplaceRoute) + return HttpRoute(this).also(::addOrReplaceRoute) } fun route(path: String): HttpRoute { - return HttpRoute(path = path).also(::addOrReplaceRoute) + val normalisedPath = PathNormaliser.normalisePath(_normalisedParams, path) + return HttpRoute(this, path = normalisedPath).also(::addOrReplaceRoute) } fun route(method: HttpMethod, path: String): HttpRoute { - return HttpRoute(path = path, method = method).also(::addOrReplaceRoute) + val normalisedPath = PathNormaliser.normalisePath(_normalisedParams, path) + return HttpRoute(this, path = normalisedPath, method = method).also(::addOrReplaceRoute) } fun routeWithRegex(method: HttpMethod, regex: String): HttpRoute { - return HttpRoute(regex = regex, method = method).also(::addOrReplaceRoute) + return HttpRoute(this, regex = regex, method = method).also(::addOrReplaceRoute) } /** diff --git a/core/http/src/main/java/io/gatehill/imposter/http/util/PathNormaliser.kt b/core/http/src/main/java/io/gatehill/imposter/http/util/PathNormaliser.kt new file mode 100644 index 000000000..4ea93575b --- /dev/null +++ b/core/http/src/main/java/io/gatehill/imposter/http/util/PathNormaliser.kt @@ -0,0 +1,90 @@ +package io.gatehill.imposter.http.util + +import io.gatehill.imposter.http.HttpRoute + +object PathNormaliser { + /** + * The permitted characters in a path parameter name. Note this is more restrictive than the Vert.x format, + * as it must also support [HttpRoute] regex named capture group names, which are defined here: + * + * https://docs.oracle.com/javase/7/docs/api/java/util/regex/Pattern.html#groupname + * + * Vert.x documentation says: + * > The placeholders consist of : followed by the parameter name. + * > Parameter names consist of any alphabetic character, numeric character or underscore. + * + * See: https://vertx.io/docs/vertx-web/java/#_capturing_path_parameters + * + * This regex pattern does not include the colon prefix or surrounding brackets. + */ + private val SAFE_PATH_PARAM_NAME = Regex("[a-zA-Z0-9]+") + + /** + * Normalises path parameters to have safe names. + * + * If a path parameter name does not match the safe path format, it is replaced with a UUID, + * and a mapping is added to the `normalisedParams` map. + * + * For example: + * ``` + * /{pathParam}/notParam + * ``` + * will be maintained as: + * ``` + * /{pathParam}/notParam + * ``` + * + * A path parameter name that does not match the safe format, such as: + * ``` + * /{param-with-dashes} + * ``` + * will be converted to: + * ``` + * /{123e4567e89b12d3a4564266141740000} + * ``` + * and the mapping `param-with-dashes -> 123e4567e89b12d3a4564266141740000` + * will be added to the `normalisedParams` map. + * + * @param normalisedParams a map to store the normalised parameter names + * @param rawPath the path to normalise + */ + fun normalisePath(normalisedParams: MutableMap, rawPath: String?): String? { + var path = rawPath + if (!path.isNullOrEmpty()) { + val matcher = HttpRoute.PATH_PARAM_PLACEHOLDER.matcher(path) + val sb = StringBuffer() + while (matcher.find()) { + val finalParamName = matcher.group(1).let { paramName -> + if (paramName.matches(SAFE_PATH_PARAM_NAME)) { + return@let paramName + } else { + val existingMapping = normalisedParams.entries.find { it.value == paramName } + if (null != existingMapping) { + return@let existingMapping.key + } + + val paramIndex = normalisedParams.size + 1 + val normalisedName = "param${paramIndex}" + normalisedParams[normalisedName] = paramName + return@let normalisedName + } + } + matcher.appendReplacement(sb, "{$finalParamName}") + } + path = matcher.appendTail(sb).toString() + } + return path + } + + fun getNormalisedParamName(normalisedParams: Map, originalParamName: String): String { + if (originalParamName.matches(SAFE_PATH_PARAM_NAME)) { + return originalParamName + } + return normalisedParams.entries.find { it.value == originalParamName }?.key ?: originalParamName + } + + fun denormaliseParams(normalisedParams: Map, exchangeParams: Map): Map { + // if it's not in the map it doesn't need to be denormalised + return exchangeParams.mapKeys { normalisedParams[it.key] ?: it.key } + } +} diff --git a/core/http/src/test/java/io/gatehill/imposter/http/HttpRouteTest.kt b/core/http/src/test/java/io/gatehill/imposter/http/HttpRouteTest.kt new file mode 100644 index 000000000..c6408a9c1 --- /dev/null +++ b/core/http/src/test/java/io/gatehill/imposter/http/HttpRouteTest.kt @@ -0,0 +1,34 @@ +package io.gatehill.imposter.http + +import io.vertx.core.Vertx +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Tests for [HttpRoute]. + */ +class HttpRouteTest { + @Test + fun `should match exact path`() { + val router = HttpRouter(Vertx.vertx()) + val route = HttpRoute(router, path = "/foo") + val matches = route.matches("/foo") + assertTrue(matches) + } + + @Test + fun `should match path with trailing wildcard`() { + val router = HttpRouter(Vertx.vertx()) + val route = HttpRoute(router, path = "/foo*") + val matches = route.matches("/foo/bar") + assertTrue(matches) + } + + @Test + fun `should match path with placeholder`() { + val router = HttpRouter(Vertx.vertx()) + val route = HttpRoute(router, path = "/foo/{id}") + val matches = route.matches("/foo/123") + assertTrue(matches) + } +} diff --git a/core/http/src/test/java/io/gatehill/imposter/http/HttpRouterTest.kt b/core/http/src/test/java/io/gatehill/imposter/http/HttpRouterTest.kt new file mode 100644 index 000000000..b3ea9eb36 --- /dev/null +++ b/core/http/src/test/java/io/gatehill/imposter/http/HttpRouterTest.kt @@ -0,0 +1,93 @@ +package io.gatehill.imposter.http + +import io.vertx.core.Vertx +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock + +/** + * Tests for [HttpRouter]. + */ +class HttpRouterTest { + @Test + fun `route should add new route`() { + val router = HttpRouter(Vertx.vertx()) + router.route("/test") + assertEquals(1, router.routes.size) + assertEquals("/test", router.routes[0].path) + } + + @Test + fun `route should replace existing route with same path and method`() { + val router = HttpRouter(Vertx.vertx()) + router.route(HttpMethod.GET, "/test") + router.route(HttpMethod.GET, "/test") + assertEquals(1, router.routes.size) + } + + @Test + fun `get should add GET route`() { + val router = HttpRouter(Vertx.vertx()) + val route = router.get("/test") + assertEquals(HttpMethod.GET, route.method) + assertEquals("/test", route.path) + } + + @Test + fun `post should add POST route`() { + val router = HttpRouter(Vertx.vertx()) + val route = router.post("/test") + assertEquals(HttpMethod.POST, route.method) + assertEquals("/test", route.path) + } + + @Test + fun `errorHandler should add error handler`() { + val router = HttpRouter(Vertx.vertx()) + val handler: HttpExchangeHandler = { } + router.errorHandler(404, handler) + assertEquals(handler, router.errorHandlers[404]) + } + + @Test + fun `route with path params should be matched`() { + val router = HttpRouter(Vertx.vertx()) + val route = router.route("/test/{param}") + assertTrue(route.matches("/test/123")) + + // no params should be normalised + assertEquals(router.normalisedParams.size, 0) + } + + @Test + fun `route with path params containing underscores should be matched`() { + val router = HttpRouter(Vertx.vertx()) + val route = router.route("/test/{param_name}") + assertTrue(route.matches("/test/123")) + + assertEquals(router.normalisedParams.size, 1) + assertTrue(router.normalisedParams.values.contains("param_name")) + } + + @Test + fun `route with multiple path params should be matched`() { + val router = HttpRouter(Vertx.vertx()) + val route = router.route("/test/{param_name}/then/{another_param}") + assertTrue(route.matches("/test/123/then/456")) + + assertEquals(router.normalisedParams.size, 2) + assertTrue(router.normalisedParams.values.contains("param_name")) + assertTrue(router.normalisedParams.values.contains("another_param")) + } + + @Test + fun `invokeBeforeEndHandlers should call all before end handlers`() { + val router = HttpRouter(Vertx.vertx()) + var called = false + val handler: HttpExchangeHandler = { called = true } + router.onBeforeEnd(handler) + router.invokeBeforeEndHandlers(mock()) + assertTrue(called) + } +} diff --git a/core/http/src/test/java/io/gatehill/imposter/http/util/PathNormaliserTest.kt b/core/http/src/test/java/io/gatehill/imposter/http/util/PathNormaliserTest.kt new file mode 100644 index 000000000..73873e395 --- /dev/null +++ b/core/http/src/test/java/io/gatehill/imposter/http/util/PathNormaliserTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2024. + * + * This file is part of Imposter. + * + * "Commons Clause" License Condition v1.0 + * + * The Software is provided to you by the Licensor under the License, as + * defined below, subject to the following condition. + * + * Without limiting other conditions in the License, the grant of rights + * under the License will not include, and the License does not grant to + * you, the right to Sell the Software. + * + * For purposes of the foregoing, "Sell" means practicing any or all of + * the rights granted to you under the License to provide to third parties, + * for a fee or other consideration (including without limitation fees for + * hosting or consulting/support services related to the Software), a + * product or service whose value derives, entirely or substantially, from + * the functionality of the Software. Any license notice or attribution + * required by the License must also include this Commons Clause License + * Condition notice. + * + * Software: Imposter + * + * License: GNU Lesser General Public License version 3 + * + * Licensor: Peter Cornish + * + * Imposter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Imposter is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Imposter. If not, see . + */ + +package io.gatehill.imposter.http.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * Tests for [PathNormaliser]. + * + * @author Pete Cornish + */ +class PathNormaliserTest { + @Test + fun `should convert path to Vertx format`() { + val normalisedParams = mutableMapOf() + val result = PathNormaliser.normalisePath(normalisedParams,"/{pathParam}/notParam") + + assertEquals(0, normalisedParams.size) + assertEquals("/{pathParam}/notParam", result) + } + + @Test + fun `should handle param and plain string`() { + val normalisedParams = mutableMapOf() + val result = PathNormaliser.normalisePath(normalisedParams,"/{firstParam}.notParam") + + assertEquals(0, normalisedParams.size) + assertEquals("/{firstParam}.notParam", result) + } + + @Test + fun `should normalise path`() { + val normalisedParams = mutableMapOf() + val result = PathNormaliser.normalisePath(normalisedParams, "/{firstParam}/{second-param}/notParam") + + assertEquals(1, normalisedParams.size) + assertFalse(normalisedParams.containsKey("firstParam")) + + val secondParam = normalisedParams.entries.first() + assertNotNull(secondParam) + assertEquals("second-param", secondParam.value) + assertNotEquals("second-param", secondParam.key) + assertEquals("/{firstParam}/{${secondParam.key}}/notParam", result) + } + + @Test + fun `should get normalised param name`() { + val normalisedParams = mapOf( + "abcdef1234567890" to "kebab-param" + ) + assertEquals("abcdef1234567890", PathNormaliser.getNormalisedParamName(normalisedParams, "kebab-param")) + assertEquals("normalParam", PathNormaliser.getNormalisedParamName(normalisedParams, "normalParam")) + } + + @Test + fun `should denormalise params`() { + val normalisedParams = mapOf( + "abcdef1234567890" to "kebab-param" + ) + val vertxParams = mapOf( + "abcdef1234567890" to "param value", + "content-type" to "application/json" + ) + val result = PathNormaliser.denormaliseParams(normalisedParams, vertxParams) + assertEquals("param value", result["kebab-param"]) + assertEquals("application/json", result["content-type"]) + } + + @Test + fun `should return null if path is null`() { + val normalisedParams = mutableMapOf() + val result = PathNormaliser.normalisePath(normalisedParams, null) + + assertEquals(0, normalisedParams.size) + assertNull(result) + } +}