From ac1b57a89ce92d5f291107f6cdd986b2b4ca3fb3 Mon Sep 17 00:00:00 2001 From: ndolgov Date: Thu, 18 Jan 2018 00:09:36 -0800 Subject: [PATCH 1/3] add initial support for URL parameter templates --- build.sbt | 15 +- .../generators/GatewayGenerator.scala | 187 +++++++++++------- .../generators/GatewayGeneratorTest.scala | 24 +++ .../handlers/GrpcGatewayHandler.scala | 47 +++-- .../scala/grpcgateway/util/PathMatcher.scala | 36 ++++ .../scala/grpcgateway/util/PathParser.scala | 66 +++++++ .../scala/grpcgateway/util/RestfulUrl.scala | 35 ++++ .../scala/grpcgateway/util/UrlTemplate.scala | 72 +++++++ .../grpcgateway/util/UrlTemplateTest.scala | 73 +++++++ 9 files changed, 460 insertions(+), 95 deletions(-) create mode 100644 generator/src/test/scala/grpcgateway/generators/GatewayGeneratorTest.scala create mode 100644 runtime/src/main/scala/grpcgateway/util/PathMatcher.scala create mode 100644 runtime/src/main/scala/grpcgateway/util/PathParser.scala create mode 100644 runtime/src/main/scala/grpcgateway/util/RestfulUrl.scala create mode 100644 runtime/src/main/scala/grpcgateway/util/UrlTemplate.scala create mode 100644 runtime/src/test/scala/grpcgateway/util/UrlTemplateTest.scala diff --git a/build.sbt b/build.sbt index 1d1902f..d818d7e 100644 --- a/build.sbt +++ b/build.sbt @@ -1,12 +1,15 @@ import com.trueaccord.scalapb.compiler.Version.{grpcJavaVersion, scalapbVersion} organization in ThisBuild := "beyondthelines" -version in ThisBuild := "0.0.8" +version in ThisBuild := "0.0.10-SNAPSHOT" licenses in ThisBuild := ("MIT", url("http://opensource.org/licenses/MIT")) :: Nil bintrayOrganization in ThisBuild := Some("beyondthelines") bintrayPackageLabels in ThisBuild := Seq("scala", "protobuf", "grpc") scalaVersion in ThisBuild := "2.12.4" +val googleapisVersion = "0.0.3" +val scalatestVersion = "3.0.4" + lazy val runtime = (project in file("runtime")) .settings( crossScalaVersions := Seq("2.12.4", "2.11.11"), @@ -16,8 +19,9 @@ lazy val runtime = (project in file("runtime")) "com.trueaccord.scalapb" %% "scalapb-runtime-grpc" % scalapbVersion, "com.trueaccord.scalapb" %% "scalapb-json4s" % "0.3.3", "io.grpc" % "grpc-netty" % grpcJavaVersion, + "org.scalatest" %% "scalatest" % scalatestVersion % Test, "org.webjars" % "swagger-ui" % "3.5.0", - "com.google.api.grpc" % "googleapis-common-protos" % "0.0.3" % "protobuf" + "com.google.api.grpc" % "googleapis-common-protos" % googleapisVersion % "protobuf" ), PB.protoSources in Compile += target.value / "protobuf_external", includeFilter in PB.generate := new SimpleFilter( @@ -35,9 +39,10 @@ lazy val generator = (project in file("generator")) crossScalaVersions := Seq("2.12.4", "2.10.6"), name := "GrpcGatewayGenerator", libraryDependencies ++= Seq( - "com.trueaccord.scalapb" %% "compilerplugin" % scalapbVersion, - "com.trueaccord.scalapb" %% "scalapb-runtime-grpc" % scalapbVersion, - "com.google.api.grpc" % "googleapis-common-protos" % "0.0.3" % "protobuf" + "com.trueaccord.scalapb" %% "compilerplugin" % scalapbVersion, + "com.trueaccord.scalapb" %% "scalapb-runtime-grpc" % scalapbVersion, + "com.google.api.grpc" % "googleapis-common-protos" % googleapisVersion % "protobuf", + "org.scalatest" %% "scalatest" % scalatestVersion % Test ), PB.protoSources in Compile += target.value / "protobuf_external", includeFilter in PB.generate := new SimpleFilter( diff --git a/generator/src/main/scala/grpcgateway/generators/GatewayGenerator.scala b/generator/src/main/scala/grpcgateway/generators/GatewayGenerator.scala index d01ac13..40f1026 100644 --- a/generator/src/main/scala/grpcgateway/generators/GatewayGenerator.scala +++ b/generator/src/main/scala/grpcgateway/generators/GatewayGenerator.scala @@ -1,6 +1,6 @@ package grpcgateway.generators -import com.google.api.AnnotationsProto +import com.google.api.{AnnotationsProto, HttpRule} import com.google.api.HttpRule.PatternCase import com.google.protobuf.Descriptors.FieldDescriptor.JavaType import com.google.protobuf.Descriptors._ @@ -10,10 +10,11 @@ import com.trueaccord.scalapb.compiler.FunctionalPrinter.PrinterEndo import com.trueaccord.scalapb.compiler.{DescriptorPimps, FunctionalPrinter} import scala.collection.JavaConverters._ +import scala.collection.mutable +import scala.collection.mutable.ArrayBuffer import scalapbshade.v0_6_7.com.trueaccord.scalapb.Scalapb object GatewayGenerator extends protocbridge.ProtocCodeGenerator with DescriptorPimps { - override val params = com.trueaccord.scalapb.compiler.GeneratorParams() override def run(requestBytes: Array[Byte]): Array[Byte] = { @@ -23,7 +24,6 @@ object GatewayGenerator extends protocbridge.ProtocCodeGenerator with Descriptor val b = CodeGeneratorResponse.newBuilder val request = CodeGeneratorRequest.parseFrom(requestBytes, registry) - val fileDescByName: Map[String, FileDescriptor] = request.getProtoFileList.asScala.foldLeft[Map[String, FileDescriptor]](Map.empty) { case (acc, fp) => @@ -50,16 +50,16 @@ object GatewayGenerator extends protocbridge.ProtocCodeGenerator with Descriptor .add( "import _root_.com.trueaccord.scalapb.GeneratedMessage", "import _root_.com.trueaccord.scalapb.json.JsonFormat", - "import _root_.grpcgateway.handlers._", - "import _root_.io.grpc._", - "import _root_.io.netty.handler.codec.http.{HttpMethod, QueryStringDecoder}" + "import _root_.grpcgateway.handlers.GrpcGatewayHandler", + "import _root_.grpcgateway.handlers.jsonException2GatewayExceptionPF", + "import _root_.io.grpc.ManagedChannel", + "import _root_.io.netty.handler.codec.http.HttpMethod" ) .newline .add( - "import scala.collection.JavaConverters._", "import scala.concurrent.{ExecutionContext, Future}", - "import com.trueaccord.scalapb.json.JsonFormatException", - "import scala.util._" + "import grpcgateway.util.{RestfulUrl, UrlTemplate}", + "import scala.util.Try" ) .newline .print(fileDesc.getServices.asScala) { case (p, s) => generateService(s)(p) } @@ -73,13 +73,14 @@ object GatewayGenerator extends protocbridge.ProtocCodeGenerator with Descriptor _.add(s"class ${service.getName}Handler(channel: ManagedChannel)(implicit ec: ExecutionContext)").indent .add( "extends GrpcGatewayHandler(channel)(ec) {", + "// a function that takes a RestfulUrl and produces a function that takes a request body and returns a response message", + "type RestfulHandler = RestfulUrl => (String) => Future[GeneratedMessage]", + "", s"""override val name: String = "${service.getName}"""", s"private val stub = ${service.getName}Grpc.stub(channel)" ) .newline - .call(generateSupportsCall(service)) - .newline - .call(generateUnaryCall(service)) + .call(generateCallSeqsByVerb(getUnaryCallsWithHttpExtension(service))) .outdent .add("}") .newline @@ -91,61 +92,12 @@ object GatewayGenerator extends protocbridge.ProtocCodeGenerator with Descriptor } } - private def generateUnaryCall(service: ServiceDescriptor): PrinterEndo = { printer => - val methods = getUnaryCallsWithHttpExtension(service) - printer - .add(s"override def unaryCall(method: HttpMethod, uri: String, body: String): Future[GeneratedMessage] = {") - .indent - .add( - "val queryString = new QueryStringDecoder(uri)", - "(method.name, queryString.path) match {" - ) - .indent - .print(methods) { case (p, m) => generateMethodHandlerCase(m)(p) } - .add("case (methodName, path) => ") - .addIndented("""Future.failed(InvalidArgument(s"No route defined for $methodName($path)"))""") - .outdent - .add("}") - .outdent - .add("}") - } - - private def generateSupportsCall(service: ServiceDescriptor): PrinterEndo = { printer => - val methods = getUnaryCallsWithHttpExtension(service) - printer - .add(s"override def supportsCall(method: HttpMethod, uri: String): Boolean = {") - .indent - .add( - "val queryString = new QueryStringDecoder(uri)", - "(method.name, queryString.path) match {" - ) - .indent - .print(methods) { case (p, m) => generateMethodCase(m)(p) } - .add("case _ => false") - .outdent - .add("}") - .outdent - .add("}") - } - - private def generateMethodCase(method: MethodDescriptor): PrinterEndo = { printer => - val http = method.getOptions.getExtension(AnnotationsProto.http) - http.getPatternCase match { - case PatternCase.GET => printer.add(s"""case ("GET", "${http.getGet}") => true""") - case PatternCase.POST => printer.add(s"""case ("POST", "${http.getPost}") => true""") - case PatternCase.PUT => printer.add(s"""case ("PUT", "${http.getPut}") => true""") - case PatternCase.DELETE => printer.add(s"""case ("DELETE", "${http.getDelete}") => true""") - case _ => printer - } - } - private def generateMethodHandlerCase(method: MethodDescriptor): PrinterEndo = { printer => val http = method.getOptions.getExtension(AnnotationsProto.http) val methodName = method.getName.charAt(0).toLower + method.getName.substring(1) http.getPatternCase match { case PatternCase.GET => printer - .add(s"""case ("GET", "${http.getGet}") => """) .indent .add("val input = Try {") .indent @@ -156,7 +108,6 @@ object GatewayGenerator extends protocbridge.ProtocCodeGenerator with Descriptor .outdent case PatternCase.POST => printer - .add(s"""case ("POST", "${http.getPost}") => """) .add("for {") .addIndented( s"""msg <- Future.fromTry(Try(JsonFormat.fromJsonString[${method.getInputType.getName}](body)).recoverWith(jsonException2GatewayExceptionPF))""", @@ -165,7 +116,6 @@ object GatewayGenerator extends protocbridge.ProtocCodeGenerator with Descriptor .add("} yield res") case PatternCase.PUT => printer - .add(s"""case ("PUT", "${http.getPut}") => """) .add("for {") .addIndented( s"""msg <- Future.fromTry(Try(JsonFormat.fromJsonString[${method.getInputType.getName}](body)).recoverWith(jsonException2GatewayExceptionPF))""", @@ -174,7 +124,6 @@ object GatewayGenerator extends protocbridge.ProtocCodeGenerator with Descriptor .add("} yield res") case PatternCase.DELETE => printer - .add(s"""case ("DELETE", "${http.getDelete}") => """) .indent .add("val input = Try {") .indent @@ -203,37 +152,37 @@ object GatewayGenerator extends protocbridge.ProtocCodeGenerator with Descriptor case JavaType.ENUM => p.add(s"val ${inputName(f, prefix)} = ") .addIndented( - s"""${f.getName}.valueOf(queryString.parameters().get("$prefix${f.getJsonName}").asScala.head)""" + s"""${f.getName}.valueOf(url.parameter("$prefix${f.getJsonName}"))""" ) case JavaType.BOOLEAN => p.add(s"val ${inputName(f, prefix)} = ") .addIndented( - s"""queryString.parameters().get("$prefix${f.getJsonName}").asScala.head.toBoolean""" + s"""url.parameter("$prefix${f.getJsonName}").toBoolean""" ) case JavaType.DOUBLE => p.add(s"val ${inputName(f, prefix)} = ") .addIndented( - s"""queryString.parameters().get("$prefix${f.getJsonName}").asScala.head.toDouble""" + s"""url.parameter("$prefix${f.getJsonName}").toDouble""" ) case JavaType.FLOAT => p.add(s"val ${inputName(f, prefix)} = ") .addIndented( - s"""queryString.parameters().get("$prefix${f.getJsonName}").asScala.head.toFloat""" + s"""url.parameter("$prefix${f.getJsonName}").toFloat""" ) case JavaType.INT => p.add(s"val ${inputName(f, prefix)} = ") .addIndented( - s"""queryString.parameters().get("$prefix${f.getJsonName}").asScala.head.toInt""" + s"""url.parameter("$prefix${f.getJsonName}").toInt""" ) case JavaType.LONG => p.add(s"val ${inputName(f, prefix)} = ") .addIndented( - s"""queryString.parameters().get("$prefix${f.getJsonName}").asScala.head.toLong""" + s"""url.parameter("$prefix${f.getJsonName}").toLong""" ) case JavaType.STRING => p.add(s"val ${inputName(f, prefix)} = ") .addIndented( - s"""queryString.parameters().get("$prefix${f.getJsonName}").asScala.head""" + s"""url.parameter("$prefix${f.getJsonName}")""" ) case jt => throw new Exception(s"Unknown java type: $jt") } @@ -246,4 +195,100 @@ object GatewayGenerator extends protocbridge.ProtocCodeGenerator with Descriptor name.charAt(0).toLower + name.substring(1) } + private def generateCallSeqsByVerb(descritors: mutable.Seq[MethodDescriptor]): PrinterEndo = { printer => + val verbToMethods: mutable.Map[PatternCase, Seq[RestfulMethod]] = MethodDescriptors.methodsByVerb(descritors) + printer + .call(generateCallSeqsByVerb(verbToMethods)) + .call(generateSupportsCall(verbToMethods.keySet)) + } + + private def generateCallSeqsByVerb(verbToMethods: mutable.Map[PatternCase, Seq[RestfulMethod]]): PrinterEndo = { printer => + printer. + print(verbToMethods) { case (p, (pattern,methods)) => generateCallSeq(pattern, methods)(p) } + } + + private def generateCallSeq(verb: PatternCase, methods: Seq[RestfulMethod]): PrinterEndo = { printer => + printer + .add(s"private val ${verb.name().toLowerCase}Calls: Seq[(UrlTemplate, RestfulHandler)] = Seq(") + .indent + .print(methods) { case (p, method) => generateCall(method)(p) } + .outdent + .add(")") // Seq + .newline + } + + private def generateCall(method: RestfulMethod): PrinterEndo = { printer => + printer + .add("(") // pair + .add( + s"""UrlTemplate("${method.urlTemplate}"),""", + "(url: RestfulUrl) => (body: String) => {" // function + ) + .indent + .call(generateMethodHandlerCase(method.method)) + .outdent + .add("}") // function + .add("),") // pair + } + + private def generateSupportsCall(verbs: collection.Set[PatternCase]): PrinterEndo = { printer => + printer + .add(s"override def supportsCall(method: HttpMethod, uri: String): Option[UnaryCall] = {") + .indent + .add("method.name match {") + .indent + .print(verbs) { case (p, verb) => generateVerbCase(verb)(p) } + .add("case _ => None") + .outdent + .add("}") // match + .outdent + .add("}") // def + } + + private def generateVerbCase(verb: PatternCase): PrinterEndo = { printer => + printer + .add(s"""case "${verb.name().toUpperCase}" =>""") + .indent + .add(s"for ((restful, handler) <- ${verb.name().toLowerCase}Calls) {") + .indent + .add("val mayBe = restful.matchUri(uri).map((url: RestfulUrl) => handler(url))") + .add("if (mayBe.isDefined) {") + .indent + .add("return mayBe") + .outdent + .add("}") //if + .outdent + .add("}") // for + .newline + .add("None") // def + .newline + .outdent // case body + } + +} + +private case class RestfulMethod(urlTemplate: String, method: MethodDescriptor) + +private object MethodDescriptors { + def methodsByVerb(descriptors: mutable.Seq[MethodDescriptor]) : mutable.Map[PatternCase, Seq[RestfulMethod]] = { + val map = mutable.Map[PatternCase, ArrayBuffer[RestfulMethod]]() + + descriptors.foreach((md: MethodDescriptor) => { + val http = md.getOptions.getExtension(AnnotationsProto.http) + val seq = map.getOrElseUpdate(http.getPatternCase, ArrayBuffer()) + seq += RestfulMethod(urlTemplate(http), md) + }) + + map.asInstanceOf[mutable.Map[PatternCase, Seq[RestfulMethod]]] // todo how to do it with "A <:" ? + } + + private def urlTemplate(http: HttpRule): String = { + http.getPatternCase match { + case PatternCase.GET => http.getGet + case PatternCase.POST => http.getPost + case PatternCase.PUT => http.getPut + case PatternCase.DELETE => http.getDelete + case _ => throw new IllegalArgumentException(s"Unsupported pattern: ${http.getPatternCase}") + } + } } diff --git a/generator/src/test/scala/grpcgateway/generators/GatewayGeneratorTest.scala b/generator/src/test/scala/grpcgateway/generators/GatewayGeneratorTest.scala new file mode 100644 index 0000000..a9cb305 --- /dev/null +++ b/generator/src/test/scala/grpcgateway/generators/GatewayGeneratorTest.scala @@ -0,0 +1,24 @@ +package grpcgateway.generators + +import java.nio.file.{Files, Paths} + +import com.google.protobuf.compiler.PluginProtos.{CodeGeneratorRequest, CodeGeneratorResponse} +import org.scalatest.{Assertions, FlatSpec} +import protocbridge.frontend.PluginFrontend + +class GatewayGeneratorTest extends FlatSpec with Assertions { + private val DIR = "generator/target/scala-2.12/test-classes/" + + it should "generate" in { + val requestProtoStream = Files.newInputStream(Paths.get(DIR + "post_request_proto.bin")) + val request = CodeGeneratorRequest.parseFrom(requestProtoStream) + + val responseBytes: Array[Byte] = PluginFrontend.runWithBytes(GatewayGenerator, request.toByteArray) + val generatedResponse = CodeGeneratorResponse.parseFrom(responseBytes) + + for (i <- 0 until generatedResponse.getFileCount) { + val file = generatedResponse.getFile(i) + Files.write(Paths.get(DIR + file.getName.substring(file.getName.lastIndexOf("/") + 1)), file.getContent.getBytes()) + } + } +} diff --git a/runtime/src/main/scala/grpcgateway/handlers/GrpcGatewayHandler.scala b/runtime/src/main/scala/grpcgateway/handlers/GrpcGatewayHandler.scala index 3598dd4..c0af37b 100644 --- a/runtime/src/main/scala/grpcgateway/handlers/GrpcGatewayHandler.scala +++ b/runtime/src/main/scala/grpcgateway/handlers/GrpcGatewayHandler.scala @@ -6,32 +6,40 @@ import com.trueaccord.scalapb.GeneratedMessage import com.trueaccord.scalapb.json.JsonFormat import io.grpc.ManagedChannel import io.netty.channel.ChannelHandler.Sharable -import io.netty.channel.{ ChannelFutureListener, ChannelHandlerContext, ChannelInboundHandlerAdapter } +import io.netty.channel.{ChannelFutureListener, ChannelHandlerContext, ChannelInboundHandlerAdapter} import io.netty.handler.codec.http._ -import scala.concurrent.{ ExecutionContext, Future } +import scala.concurrent.{ExecutionContext, Future} @Sharable abstract class GrpcGatewayHandler(channel: ManagedChannel)(implicit ec: ExecutionContext) extends ChannelInboundHandlerAdapter { + /** a function that takes a request body and returns a response message */ + type UnaryCall = (String) => Future[GeneratedMessage] def name: String def shutdown(): Unit = if (!channel.isShutdown) channel.shutdown() - def supportsCall(method: HttpMethod, uri: String): Boolean - def unaryCall(method: HttpMethod, uri: String, body: String): Future[GeneratedMessage] + /** + * @param method HTTP verb + * @param uri request path + * @return response message + */ + def supportsCall(method: HttpMethod, uri: String): Option[UnaryCall] override def channelRead(ctx: ChannelHandlerContext, msg: scala.Any): Unit = { msg match { case req: FullHttpRequest => - if (supportsCall(req.method(), req.uri())) { + val mayBeCall: Option[UnaryCall] = supportsCall(req.method(), req.uri()) + if (mayBeCall.isDefined) { + val unaryCall = mayBeCall.get val body = req.content().toString(StandardCharsets.UTF_8) - unaryCall(req.method(), req.uri(), body) + unaryCall(body) .map(JsonFormat.toJsonString) .map(json => { buildFullHttpResponse( @@ -43,24 +51,25 @@ abstract class GrpcGatewayHandler(channel: ManagedChannel)(implicit ec: Executio }) .recover({ case err => - val (body, status) = err match { - case e: GatewayException => e.details -> GRPC_HTTP_CODE_MAP.getOrElse(e.code, HttpResponseStatus.INTERNAL_SERVER_ERROR) - case _ => "Internal error" -> HttpResponseStatus.INTERNAL_SERVER_ERROR - } + val (body, status) = err match { + case e: GatewayException => e.details -> GRPC_HTTP_CODE_MAP.getOrElse(e.code, HttpResponseStatus.INTERNAL_SERVER_ERROR) + case _ => "Internal error" -> HttpResponseStatus.INTERNAL_SERVER_ERROR + } - buildFullHttpResponse( - requestMsg = req, - responseBody = body, - responseStatus = status, - responseContentType = "application/text" - ) - }).foreach(resp => { - ctx.writeAndFlush(resp).addListener(ChannelFutureListener.CLOSE) - }) + buildFullHttpResponse( + requestMsg = req, + responseBody = body, + responseStatus = status, + responseContentType = "application/text" + ) + }).foreach(resp => { + ctx.writeAndFlush(resp).addListener(ChannelFutureListener.CLOSE) + }) } else { super.channelRead(ctx, msg) } + case _ => super.channelRead(ctx, msg) } } diff --git a/runtime/src/main/scala/grpcgateway/util/PathMatcher.scala b/runtime/src/main/scala/grpcgateway/util/PathMatcher.scala new file mode 100644 index 0000000..aacd686 --- /dev/null +++ b/runtime/src/main/scala/grpcgateway/util/PathMatcher.scala @@ -0,0 +1,36 @@ +package grpcgateway.util + +import scala.collection.mutable + +private[util] trait PathMatcher { + def matchString(str: String, from: Int, templateParams: mutable.Map[String, String]) : Int +} + +private[util] final class TextMatcher(prefix: String) extends PathMatcher { + override def matchString(str: String, from: Int, map: mutable.Map[String, String]): Int = { + val to = from + prefix.length + if (str.substring(from, to) == prefix) { + to + } else { + throw new IllegalArgumentException(s"Prefix $prefix not found at $from in $str") + } + } + + override def toString: String = prefix +} + +private[util] final class TemplateMatcher(name: String) extends PathMatcher { + override def matchString(str: String, from: Int, map: mutable.Map[String, String]): Int = { + var index = from + while ((index < str.length) && (str(index) != '/')) { + index += 1 + } + + map.put(name, str.substring(from, index)) + + index + } + + override def toString: String = s"[$name]" +} + diff --git a/runtime/src/main/scala/grpcgateway/util/PathParser.scala b/runtime/src/main/scala/grpcgateway/util/PathParser.scala new file mode 100644 index 0000000..283c554 --- /dev/null +++ b/runtime/src/main/scala/grpcgateway/util/PathParser.scala @@ -0,0 +1,66 @@ +package grpcgateway.util + +import scala.collection.mutable.ArrayBuffer + +private[util] final class PathParser(path: String) { + import PathParser.{LCURLY, RCURLY} + + private val matchers = ArrayBuffer[PathMatcher]() + private var index = 0 + + private def matchChar(ch: Char): Unit = { + if (path(index) == ch) { + index += 1 + } else { + throw new IllegalArgumentException(s"Unexpected character ${path(index)} at $index in $path") + } + } + + private def matchTemplate(): TemplateMatcher = { + matchChar(LCURLY) + + val from = index + while ((index < path.length) && (path(index) != RCURLY)) { + index += 1 + } + val name = path.substring(from, index) + + matchChar(RCURLY) + + new TemplateMatcher(name) + } + + private def matchPrefix(): TextMatcher = { + val from = index + while ((index < path.length) && (path(index) != LCURLY)) { + index += 1 + } + + new TextMatcher(path.substring(from, index)) + } + + def parse() : Seq[PathMatcher] = { + while (index < path.length) { + path(index) match { + case LCURLY => + val matcher = matchTemplate() + matchers += matcher + + case _ => + val matcher = matchPrefix() + matchers += matcher + } + } + + matchers + } + + override def toString: String = matchers.mkString +} + +private[util] object PathParser { + private val LCURLY = '{' + private val RCURLY = '}' + + def hasTemplates(path: String) : Boolean = path.contains(PathParser.LCURLY) +} diff --git a/runtime/src/main/scala/grpcgateway/util/RestfulUrl.scala b/runtime/src/main/scala/grpcgateway/util/RestfulUrl.scala new file mode 100644 index 0000000..d0955e8 --- /dev/null +++ b/runtime/src/main/scala/grpcgateway/util/RestfulUrl.scala @@ -0,0 +1,35 @@ +package grpcgateway.util + +import java.util + +import RestfulUrl._ + +import scala.collection.JavaConverters._ +import scala.collection.mutable + +/** A container of extracted URI properties */ +trait RestfulUrl { + /** @return named URL parameter extracted from a query uri with a UrlTemplate */ + def parameter(name: String): String +} + +private final class PlainRestfulUrl(parameters: PathParams) extends RestfulUrl { + override def parameter(name: String): String = parameters.get(name).asScala.head +} + +private final class MergedRestfulUrl(templateParams: TemplateParams, pathParams: PathParams) extends RestfulUrl { + override def parameter(name: String): String = { + if (templateParams.contains(name)) { + templateParams(name) + } else if (pathParams.containsKey(name)) { + pathParams.get(name).asScala.head + } else { + null //throw new IllegalArgumentException(s"Property not found: $name") + } + } +} + +private object RestfulUrl { + type PathParams = util.Map[String, util.List[String]] + type TemplateParams = mutable.Map[String, String] +} diff --git a/runtime/src/main/scala/grpcgateway/util/UrlTemplate.scala b/runtime/src/main/scala/grpcgateway/util/UrlTemplate.scala new file mode 100644 index 0000000..991165b --- /dev/null +++ b/runtime/src/main/scala/grpcgateway/util/UrlTemplate.scala @@ -0,0 +1,72 @@ +package grpcgateway.util + +import io.netty.handler.codec.http.QueryStringDecoder + +import scala.collection.mutable + +/** A means of parsing request URLs with support for "URL parameter templates" configured in a protobuf descriptor */ +trait UrlTemplate { + /** + * Match incoming URI against this URL template generated from a protobuf RESTful service descriptor + * + * Netty's FullHttpRequest.uri() happens to return a path so we currently assume the "protocol/host" prefix + * is stripped before this method is called. + * + * @return URL properties if uri matches this template + */ + def matchUri(uri: String) : Option[RestfulUrl] +} + +private final class PlainUrlTemplate(path: String) extends UrlTemplate { + override def matchUri(uri: String): Option[RestfulUrl] = { + println(s"Matching \'$uri\' to $path") + + val decoder = new QueryStringDecoder(uri) + if (decoder.path() == path) { + Some(new PlainRestfulUrl(decoder.parameters())) + } else { + None + } + } +} + +private final class MatchingUrlTemplate(matchers: Seq[PathMatcher]) extends UrlTemplate { + private val templateParams = mutable.Map[String, String]() + + override def matchUri(uri: String): Option[RestfulUrl] = { + val decoder = new QueryStringDecoder(uri) + val path = decoder.path() + + println(s"Matching \'$path\' with ${matchers.mkString}") + + var pathIndex = 0 + var matcherIndex = 0 + + while (pathIndex < path.length) { + val from = pathIndex + + val matcher = matchers(matcherIndex) + pathIndex = matcher.matchString(path, pathIndex, templateParams) + + println(s"Matched \'${path.substring(from, pathIndex)}\' with ${matcher.toString} remains [${path.substring(pathIndex)}]") + + matcherIndex += 1 + } + + if (matcherIndex == matchers.size) { + Some(new MergedRestfulUrl(templateParams, decoder.parameters())) + } else { + None + } + } +} + +object UrlTemplate { + def apply(path: String) : UrlTemplate = { + if (PathParser.hasTemplates(path)) { + new MatchingUrlTemplate(new PathParser(path).parse()) + } else { + new PlainUrlTemplate(path) + } + } +} \ No newline at end of file diff --git a/runtime/src/test/scala/grpcgateway/util/UrlTemplateTest.scala b/runtime/src/test/scala/grpcgateway/util/UrlTemplateTest.scala new file mode 100644 index 0000000..7deb146 --- /dev/null +++ b/runtime/src/test/scala/grpcgateway/util/UrlTemplateTest.scala @@ -0,0 +1,73 @@ +package grpcgateway.util + +import org.scalatest.{Assertions, FlatSpec} + +class UrlTemplateTest extends FlatSpec with Assertions { + private val KEY = "k" + private val VALUE = "v" + private val PARAM1 = "T123" + private val PARAM2 = "Param456" + + it should "preserve default semantics for fixed requests" in { + val template = "/tree/trunk/branch/leaf/get" + + val url = UrlTemplate(template) + val restful = url.matchUri(template).get + assert(restful != null) + + val kvurl = UrlTemplate(template) + val kvrestful = kvurl.matchUri(s"$template?$KEY=$VALUE").get + assert(kvrestful.parameter(KEY) == VALUE) + } + + it should "support multiple URL parameter templates" in { + assertTwoParams( + UrlTemplate("/tree/trunk/branch/leaf/get/{template}/padding/{param}/"), + s"/tree/trunk/branch/leaf/get/$PARAM1/padding/$PARAM2/") + + assertTwoParams( + UrlTemplate("/tree/trunk/branch/leaf/get/{template}/{param}/suffix"), + s"/tree/trunk/branch/leaf/get/$PARAM1/$PARAM2/suffix") + + assertTwoParams( + UrlTemplate("/tree/trunk/branch/leaf/get/{template}/{param}"), + s"/tree/trunk/branch/leaf/get/$PARAM1/$PARAM2") + + assertTwoParams( + UrlTemplate("/tree/trunk/branch/leaf/get/{template}/{param}/"), + s"/tree/trunk/branch/leaf/get/$PARAM1/$PARAM2/") + } + + it should "merge template and ordinary URL parameters" in { + assertMixedParams( + UrlTemplate("/tree/trunk/branch/leaf/get/{template}/padding/{param}/"), + s"/tree/trunk/branch/leaf/get/$PARAM1/padding/$PARAM2/?$KEY=$VALUE") + + assertMixedParams( + UrlTemplate("/tree/trunk/branch/leaf/get/{template}/{param}/suffix"), + s"/tree/trunk/branch/leaf/get/$PARAM1/$PARAM2/suffix?$KEY=$VALUE") + + assertMixedParams( + UrlTemplate("/tree/trunk/branch/leaf/get/{template}/{param}"), + s"/tree/trunk/branch/leaf/get/$PARAM1/$PARAM2?$KEY=$VALUE") + + assertMixedParams( + UrlTemplate("/tree/trunk/branch/leaf/get/{template}/{param}/"), + s"/tree/trunk/branch/leaf/get/$PARAM1/$PARAM2/?$KEY=$VALUE") + } + + private def assertTwoParams(template: UrlTemplate, uri: String): Unit = { + val restful = template.matchUri(uri).get + assert(restful.parameter("template") == PARAM1) + assert(restful.parameter("param") == PARAM2) + assert(restful.parameter(KEY) == null) + } + + private def assertMixedParams(template: UrlTemplate, uri: String): Unit = { + val restful = template.matchUri(uri).get + assert(restful.parameter("template") == PARAM1) + assert(restful.parameter("param") == PARAM2) + assert(restful.parameter(KEY) == VALUE) + assert(restful.parameter("") == null) + } +} \ No newline at end of file From 1508e3db377b58049d711f195525ea46e35a96d9 Mon Sep 17 00:00:00 2001 From: ndolgov Date: Thu, 18 Jan 2018 21:43:36 -0800 Subject: [PATCH 2/3] add a CodeGeneratorRequest-based unit test; disable debug logging; update scaladocs --- .../src/test/resources/objectstore_proto.bin | Bin 0 -> 60994 bytes .../generators/GatewayGeneratorTest.scala | 2 +- .../scala/grpcgateway/util/PathMatcher.scala | 19 ++++++++++++++---- .../scala/grpcgateway/util/PathParser.scala | 17 ++++++++++++---- .../scala/grpcgateway/util/RestfulUrl.scala | 7 ++++++- .../scala/grpcgateway/util/UrlTemplate.scala | 19 ++++++++++-------- 6 files changed, 46 insertions(+), 18 deletions(-) create mode 100644 generator/src/test/resources/objectstore_proto.bin diff --git a/generator/src/test/resources/objectstore_proto.bin b/generator/src/test/resources/objectstore_proto.bin new file mode 100644 index 0000000000000000000000000000000000000000..c556f810e6421b86e061d27d0326e35222bc340a GIT binary patch literal 60994 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 z zHxgI?hAqPaN%Bo!DV!y&Es;Tc9<0cfq~j4~@3Q*>2E+AUo@F7frcD{#V;~Qp9(C_f zfkIOD^e(56l>H})6q2(4MEB0iGYV>uIs$aimI6%X!U1=!=_DKsH zJMH;Sc7O0c{>ngF8tgr^D^{f0;p2Qd&Yjug2IL5u34^6j;YeSuP21-8Ia9j-SsZ-z z>b9<{O36p7Noh;HvaPFr=ohP4ic6Q4bv;} z@sU%LW1|!4>GAaF=~KtXCdW>nqI7RMa_V*IYsO9;!>PftlCdDG2>Zvf0YXThlI+L) zIk9XLG>2_MSPloaxG*_nSm^F(Qn5Ja7sfq+ge!WIQn_B~>!~N3 zxzePoUa4=o#(tLSm7SZ0^uDKFxvXn|slRGY1}khyYUW+y)g~py=?=aoD{f0Rc@Y?T z9V%BSU431EczM^Y0V0)D@Jd4F@=ZDkC5WVI@S;Ps%nkNBRNlnfYijKI8)O@8l1Z|seC194qFdjEO0=_wbF=mVl-N`6pXxSsaob}b+MetPA#1wG zFleY2%| zITLUTUkv=cWam+f9(-{)6G2r36Y%bMHgG)I)d}+3puaoWr0h!z85v@6U`=cFdQw$V zAnX2?ar=Gf|0*YS$9+O$H{sVm)PKB`Y$j{Gzp3A}F+~a_wxR0Z`)%uaUj|S65xX5Uw(} zoCobx-E<=&RPM$O?e5xHk~nosgu8pBW_TvEeN(l!CV^Iocbj^4#1g0I3)omG)_6#JqUB6?qC`@F2^|G$rMq$OQS1#j3A5#Fq-oU!N>ba?$Y?oIr zGbOKgHN8}lznV&XcRBHXp+r|M-*s7l!mBH)9@glV{1w&9FAp`b<9QjOh&)#i5b03x zg`=XcFdQLcf7jKHqiXfDC>4%2Z|ou0nO1@nq?D_;&jo=Z(D!iNQ7G}<9_SfLK;J_O zv>OZxudX6K{t8e1MH&p(e}t@IU}1DPcn!)`)jgL55Jb20RRk!suHxc!2QTEW0ud3I zmB+PR1CBhVe&t$%goV6KQE4DB1)!(ZYjvY{P47TH4W#keD-8su1oSizSfT2b>o`^* z94P?hx@zhKrU;blGAP3FIxai%7KGz<0Y}W~8@l#7ju_jO8<0fdSP>K47zj)O=r>kx z(3R{py#xAnDLtqDvmz~5B8(Fsfu@HYkIDG5ezJv1%k zZ@TPqBQReLNu8i0V2OHV2oV$VRUyBx>JhdSsJE{=T%-!{{gF|!tek9 literal 0 HcmV?d00001 diff --git a/generator/src/test/scala/grpcgateway/generators/GatewayGeneratorTest.scala b/generator/src/test/scala/grpcgateway/generators/GatewayGeneratorTest.scala index a9cb305..e3102f9 100644 --- a/generator/src/test/scala/grpcgateway/generators/GatewayGeneratorTest.scala +++ b/generator/src/test/scala/grpcgateway/generators/GatewayGeneratorTest.scala @@ -10,7 +10,7 @@ class GatewayGeneratorTest extends FlatSpec with Assertions { private val DIR = "generator/target/scala-2.12/test-classes/" it should "generate" in { - val requestProtoStream = Files.newInputStream(Paths.get(DIR + "post_request_proto.bin")) + val requestProtoStream = Files.newInputStream(Paths.get(DIR + "objectstore_proto.bin")) val request = CodeGeneratorRequest.parseFrom(requestProtoStream) val responseBytes: Array[Byte] = PluginFrontend.runWithBytes(GatewayGenerator, request.toByteArray) diff --git a/runtime/src/main/scala/grpcgateway/util/PathMatcher.scala b/runtime/src/main/scala/grpcgateway/util/PathMatcher.scala index aacd686..1342818 100644 --- a/runtime/src/main/scala/grpcgateway/util/PathMatcher.scala +++ b/runtime/src/main/scala/grpcgateway/util/PathMatcher.scala @@ -3,12 +3,21 @@ package grpcgateway.util import scala.collection.mutable private[util] trait PathMatcher { + /** + * Assume a sequence of matchers was created by sequentially scanning a URL pattern such as "/get/{template}". + * One by one apply all matchers in the original order + * @param str URL path string + * @param from position in the path to start matching from + * @param templateParams (name,value) pairs of URL parameters extracted from named slots + * @return the position in string the next matcher should continue from + */ def matchString(str: String, from: Int, templateParams: mutable.Map[String, String]) : Int } private[util] final class TextMatcher(prefix: String) extends PathMatcher { - override def matchString(str: String, from: Int, map: mutable.Map[String, String]): Int = { + override def matchString(str: String, from: Int, templateParams: mutable.Map[String, String]): Int = { val to = from + prefix.length + if (str.substring(from, to) == prefix) { to } else { @@ -20,13 +29,15 @@ private[util] final class TextMatcher(prefix: String) extends PathMatcher { } private[util] final class TemplateMatcher(name: String) extends PathMatcher { - override def matchString(str: String, from: Int, map: mutable.Map[String, String]): Int = { + private val PATH_DELIMITER = '/' + + override def matchString(str: String, from: Int, templateParams: mutable.Map[String, String]): Int = { var index = from - while ((index < str.length) && (str(index) != '/')) { + while ((index < str.length) && (str(index) != PATH_DELIMITER)) { index += 1 } - map.put(name, str.substring(from, index)) + templateParams.put(name, str.substring(from, index)) index } diff --git a/runtime/src/main/scala/grpcgateway/util/PathParser.scala b/runtime/src/main/scala/grpcgateway/util/PathParser.scala index 283c554..ecb678f 100644 --- a/runtime/src/main/scala/grpcgateway/util/PathParser.scala +++ b/runtime/src/main/scala/grpcgateway/util/PathParser.scala @@ -2,7 +2,7 @@ package grpcgateway.util import scala.collection.mutable.ArrayBuffer -private[util] final class PathParser(path: String) { +private final class PathParser(path: String) { import PathParser.{LCURLY, RCURLY} private val matchers = ArrayBuffer[PathMatcher]() @@ -16,6 +16,7 @@ private[util] final class PathParser(path: String) { } } + /** Assume the index points to a named slot. Remember the extracted slot name. */ private def matchTemplate(): TemplateMatcher = { matchChar(LCURLY) @@ -30,7 +31,11 @@ private[util] final class PathParser(path: String) { new TemplateMatcher(name) } - private def matchPrefix(): TextMatcher = { + /** + * Assume the index points to somewhere in-between named slots. + * Collect everything up to the next named slot (or the end of the input) + */ + private def matchStaticText(): TextMatcher = { val from = index while ((index < path.length) && (path(index) != LCURLY)) { index += 1 @@ -39,7 +44,7 @@ private[util] final class PathParser(path: String) { new TextMatcher(path.substring(from, index)) } - def parse() : Seq[PathMatcher] = { + private def parse() : Seq[PathMatcher] = { while (index < path.length) { path(index) match { case LCURLY => @@ -47,7 +52,7 @@ private[util] final class PathParser(path: String) { matchers += matcher case _ => - val matcher = matchPrefix() + val matcher = matchStaticText() matchers += matcher } } @@ -62,5 +67,9 @@ private[util] object PathParser { private val LCURLY = '{' private val RCURLY = '}' + /** @return true if the path contains at least one parameter template such as "/{slot}" */ def hasTemplates(path: String) : Boolean = path.contains(PathParser.LCURLY) + + /** Sequentially scan a URL template string. Split it into segments representing names slots and everything else. */ + def apply(path: String) : Seq[PathMatcher] = new PathParser(path).parse() } diff --git a/runtime/src/main/scala/grpcgateway/util/RestfulUrl.scala b/runtime/src/main/scala/grpcgateway/util/RestfulUrl.scala index d0955e8..6eb2826 100644 --- a/runtime/src/main/scala/grpcgateway/util/RestfulUrl.scala +++ b/runtime/src/main/scala/grpcgateway/util/RestfulUrl.scala @@ -9,7 +9,9 @@ import scala.collection.mutable /** A container of extracted URI properties */ trait RestfulUrl { - /** @return named URL parameter extracted from a query uri with a UrlTemplate */ + /** + * A uniform way to access URL parameters extracted from named slots (e.g. "/{slot}/") and ordinary parameters (e.g. "?k=v") + * @return named URL parameter extracted from a query uri with a UrlTemplate */ def parameter(name: String): String } @@ -30,6 +32,9 @@ private final class MergedRestfulUrl(templateParams: TemplateParams, pathParams: } private object RestfulUrl { + /** parameters extracted from named slots such as "/{slot}/" */ type PathParams = util.Map[String, util.List[String]] + + /** ordinary parameters such as "?k=v" */ type TemplateParams = mutable.Map[String, String] } diff --git a/runtime/src/main/scala/grpcgateway/util/UrlTemplate.scala b/runtime/src/main/scala/grpcgateway/util/UrlTemplate.scala index 991165b..f79ee37 100644 --- a/runtime/src/main/scala/grpcgateway/util/UrlTemplate.scala +++ b/runtime/src/main/scala/grpcgateway/util/UrlTemplate.scala @@ -12,14 +12,15 @@ trait UrlTemplate { * Netty's FullHttpRequest.uri() happens to return a path so we currently assume the "protocol/host" prefix * is stripped before this method is called. * - * @return URL properties if uri matches this template + * @return URL properties extracted from the URI if it matched this template */ def matchUri(uri: String) : Option[RestfulUrl] } +/** Fast path for URL patterns with no templates */ private final class PlainUrlTemplate(path: String) extends UrlTemplate { override def matchUri(uri: String): Option[RestfulUrl] = { - println(s"Matching \'$uri\' to $path") + //println(s"Matching \'$uri\' to $path") val decoder = new QueryStringDecoder(uri) if (decoder.path() == path) { @@ -30,25 +31,26 @@ private final class PlainUrlTemplate(path: String) extends UrlTemplate { } } +/** Remember a sequence of matchers to apply (in the same order) to an incoming URI. While matching, optimistically + * collect values at positions corresponding to names slots in the original URL template */ private final class MatchingUrlTemplate(matchers: Seq[PathMatcher]) extends UrlTemplate { - private val templateParams = mutable.Map[String, String]() - override def matchUri(uri: String): Option[RestfulUrl] = { val decoder = new QueryStringDecoder(uri) val path = decoder.path() - println(s"Matching \'$path\' with ${matchers.mkString}") + //println(s"Matching \'$path\' with ${matchers.mkString}") var pathIndex = 0 var matcherIndex = 0 + val templateParams = mutable.Map[String, String]() while (pathIndex < path.length) { val from = pathIndex val matcher = matchers(matcherIndex) pathIndex = matcher.matchString(path, pathIndex, templateParams) - println(s"Matched \'${path.substring(from, pathIndex)}\' with ${matcher.toString} remains [${path.substring(pathIndex)}]") + //println(s"Matched \'${path.substring(from, pathIndex)}\' with ${matcher.toString} remains [${path.substring(pathIndex)}]") matcherIndex += 1 } @@ -62,11 +64,12 @@ private final class MatchingUrlTemplate(matchers: Seq[PathMatcher]) extends UrlT } object UrlTemplate { + /** Parse a URL template into a URL matcher. Use a fast path for templates with no named slots. */ def apply(path: String) : UrlTemplate = { if (PathParser.hasTemplates(path)) { - new MatchingUrlTemplate(new PathParser(path).parse()) + new MatchingUrlTemplate(PathParser(path)) } else { new PlainUrlTemplate(path) } } -} \ No newline at end of file +} From 8600e2b1361a1d538e8805694cebc11833847cab Mon Sep 17 00:00:00 2001 From: ndolgov Date: Thu, 18 Jan 2018 23:54:59 -0800 Subject: [PATCH 3/3] throw exceptons only when an invalid URL pattern is detected --- .../scala/grpcgateway/util/PathMatcher.scala | 8 +++-- .../scala/grpcgateway/util/PathParser.scala | 7 ++++ .../scala/grpcgateway/util/UrlTemplate.scala | 3 ++ .../grpcgateway/util/UrlTemplateTest.scala | 34 +++++++++++++++++-- 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/runtime/src/main/scala/grpcgateway/util/PathMatcher.scala b/runtime/src/main/scala/grpcgateway/util/PathMatcher.scala index 1342818..f18c6bb 100644 --- a/runtime/src/main/scala/grpcgateway/util/PathMatcher.scala +++ b/runtime/src/main/scala/grpcgateway/util/PathMatcher.scala @@ -18,10 +18,10 @@ private[util] final class TextMatcher(prefix: String) extends PathMatcher { override def matchString(str: String, from: Int, templateParams: mutable.Map[String, String]): Int = { val to = from + prefix.length - if (str.substring(from, to) == prefix) { + if ((to <= str.length) && (str.substring(from, to) == prefix)) { to } else { - throw new IllegalArgumentException(s"Prefix $prefix not found at $from in $str") + PathMatcher.NO_MATCH } } @@ -45,3 +45,7 @@ private[util] final class TemplateMatcher(name: String) extends PathMatcher { override def toString: String = s"[$name]" } +object PathMatcher { + /** The "string position" value to return if a string does not match this matcher */ + val NO_MATCH: Int = -1 +} \ No newline at end of file diff --git a/runtime/src/main/scala/grpcgateway/util/PathParser.scala b/runtime/src/main/scala/grpcgateway/util/PathParser.scala index ecb678f..4984396 100644 --- a/runtime/src/main/scala/grpcgateway/util/PathParser.scala +++ b/runtime/src/main/scala/grpcgateway/util/PathParser.scala @@ -22,6 +22,9 @@ private final class PathParser(path: String) { val from = index while ((index < path.length) && (path(index) != RCURLY)) { + if (path(index) == LCURLY) { + throw new IllegalArgumentException(s"Detected curly braces mismatch at ${from-1} and $index in $path") + } index += 1 } val name = path.substring(from, index) @@ -38,6 +41,10 @@ private final class PathParser(path: String) { private def matchStaticText(): TextMatcher = { val from = index while ((index < path.length) && (path(index) != LCURLY)) { + if (path(index) == RCURLY) { + throw new IllegalArgumentException(s"Detected curly braces mismatch at ${from} and $index in $path") + } + index += 1 } diff --git a/runtime/src/main/scala/grpcgateway/util/UrlTemplate.scala b/runtime/src/main/scala/grpcgateway/util/UrlTemplate.scala index f79ee37..b650976 100644 --- a/runtime/src/main/scala/grpcgateway/util/UrlTemplate.scala +++ b/runtime/src/main/scala/grpcgateway/util/UrlTemplate.scala @@ -49,6 +49,9 @@ private final class MatchingUrlTemplate(matchers: Seq[PathMatcher]) extends UrlT val matcher = matchers(matcherIndex) pathIndex = matcher.matchString(path, pathIndex, templateParams) + if (pathIndex == PathMatcher.NO_MATCH) { + return None + } //println(s"Matched \'${path.substring(from, pathIndex)}\' with ${matcher.toString} remains [${path.substring(pathIndex)}]") diff --git a/runtime/src/test/scala/grpcgateway/util/UrlTemplateTest.scala b/runtime/src/test/scala/grpcgateway/util/UrlTemplateTest.scala index 7deb146..edf1654 100644 --- a/runtime/src/test/scala/grpcgateway/util/UrlTemplateTest.scala +++ b/runtime/src/test/scala/grpcgateway/util/UrlTemplateTest.scala @@ -1,8 +1,8 @@ package grpcgateway.util -import org.scalatest.{Assertions, FlatSpec} +import org.scalatest.{Assertions, FlatSpec, Matchers} -class UrlTemplateTest extends FlatSpec with Assertions { +class UrlTemplateTest extends FlatSpec with Matchers with Assertions { private val KEY = "k" private val VALUE = "v" private val PARAM1 = "T123" @@ -56,6 +56,32 @@ class UrlTemplateTest extends FlatSpec with Assertions { s"/tree/trunk/branch/leaf/get/$PARAM1/$PARAM2/?$KEY=$VALUE") } + it should "no match is found in mismatched URI" in { + assertNoMatch( + UrlTemplate("/tree/trunk/branch/leaf/get/{template}/padding/{param}/"), + s"/tree/trunk/branch/leaf/get/$PARAM1/padding") + + assertNoMatch( + UrlTemplate("/tree/trunk/branch/leaf/get/{template}"), + s"/tree/trunk/branch/leaf/get") + + assertNoMatch( + UrlTemplate("/tree/trunk/branch/leaf/get/padding"), + s"/tree/trunk/branch/leaf/get") + } + + an [IllegalArgumentException] should be thrownBy { + assertNoMatch( + UrlTemplate("/tree/trunk/branch/leaf/get/{template/padding/{param}/"), + s"IGNORED") + } + + an [IllegalArgumentException] should be thrownBy { + assertNoMatch( + UrlTemplate("/tree/trunk/branch/leaf/get/template}/padding/{param}/"), + s"IGNORED") + } + private def assertTwoParams(template: UrlTemplate, uri: String): Unit = { val restful = template.matchUri(uri).get assert(restful.parameter("template") == PARAM1) @@ -70,4 +96,8 @@ class UrlTemplateTest extends FlatSpec with Assertions { assert(restful.parameter(KEY) == VALUE) assert(restful.parameter("") == null) } + + private def assertNoMatch(template: UrlTemplate, uri: String): Unit = { + assert(template.matchUri(uri).isEmpty) + } } \ No newline at end of file