diff --git a/modules/lepus-router/src/main/scala/lepus/router/DecodeEndpoint.scala b/modules/lepus-router/src/main/scala/lepus/router/DecodeEndpoint.scala index 0f39fc4c..5d4d9c60 100644 --- a/modules/lepus-router/src/main/scala/lepus/router/DecodeEndpoint.scala +++ b/modules/lepus-router/src/main/scala/lepus/router/DecodeEndpoint.scala @@ -23,7 +23,7 @@ private[lepus] object DecodeEndpoint: val endpoints = endpoint.asVector() val endpointPaths = endpoints.filter(_.isPath) - val endpointQueryParams = endpoints.filter(_.isQueryParam) + val endpointQueryParams = endpoints.filter(_.isQuery) decodingConvolution( tailrecMatchPath(DecodePathRequest(request), endpointPaths, _, _), diff --git a/modules/lepus-router/src/main/scala/lepus/router/http/Endpoint.scala b/modules/lepus-router/src/main/scala/lepus/router/http/Endpoint.scala index a823d9d9..5409f6bc 100644 --- a/modules/lepus-router/src/main/scala/lepus/router/http/Endpoint.scala +++ b/modules/lepus-router/src/main/scala/lepus/router/http/Endpoint.scala @@ -13,6 +13,7 @@ import Endpoint.* sealed trait Endpoint[T]: private[lepus] type TypeParam = T private[lepus] type ThisType <: Endpoint[T] + @targetName("and") def ++[N, TN](other: Endpoint[N])(using ParamConcat.Aux[T, N, TN]): Endpoint[TN] = Pair[T, N, TN](this, other) @@ -21,6 +22,66 @@ sealed trait Endpoint[T]: @targetName("queryQ") def +?[N, TN](query: Query[N])(using ParamConcat.Aux[T, N, TN]): Endpoint[TN] = this ++ query @targetName("query&") def +&[N, TN](query: Query[N])(using ParamConcat.Aux[T, N, TN]): Endpoint[TN] = this ++ query + /** Method for storing nested Endpoints in Vector. */ + def asVector(): Vector[Endpoint[?]] = + this match + case Endpoint.Pair(left, right) => left.asVector() ++ right.asVector() + case r: Endpoint[?] => Vector(r) + + /** A method to generate a Path string using only the Endpoint's Path information. + * + * @param format + * Argument to specify the format of the path parameter string. + * @return + * A Path String + */ + def toPath(format: String => String = (s: String) => s"{$s}"): String = "/" + asVector() + .map { + case e: Endpoint.FixedPath[?] => e.name + case e: Endpoint.Path[?] => format(e.name) + case _ => "" + } + .filter(_.nonEmpty) + .mkString("/") + + /** A method to generate a string of paths for use with Scala's format method. */ + def toFormatPath: String = toPath(_ => "%s") + + /** A method to generate a Query string using only the Endpoint's Query information. + * + * @param format + * Argument to specify the format of the query parameter string. + * @return + * A Query String + */ + def toQuery(format: String => String = (s: String) => s"{$s}"): String = asVector() + .map { + case e: Endpoint.Query[?] => format(e.key) + case _ => "" + } + .filter(_.nonEmpty) + .mkString("&") + + /** A method to generate a string of queries for use with Scala's format method. */ + def toFormatQuery: String = toQuery(s => s"$s=%s") + + /** Method to determine if the Endpoint is a Path. */ + def isPath: Boolean = this match + case _: Endpoint.Path[?] => true + case _ => false + + /** Method to determine if the Endpoint is a Query. */ + def isQuery: Boolean = this match + case _: Endpoint.Query[?] => true + case _ => false + + /** Value for generating a string of paths using the format method. */ + def formatString: String = + val query: String = if toFormatQuery.nonEmpty then "?" + toFormatQuery else "" + toFormatPath + query + + override def toString: String = formatString + object Endpoint: /** Model for representing endpoint parameters diff --git a/modules/lepus-router/src/main/scala/lepus/router/internal/ExtensionMethods.scala b/modules/lepus-router/src/main/scala/lepus/router/internal/ExtensionMethods.scala index 94a91bcd..054fb339 100644 --- a/modules/lepus-router/src/main/scala/lepus/router/internal/ExtensionMethods.scala +++ b/modules/lepus-router/src/main/scala/lepus/router/internal/ExtensionMethods.scala @@ -8,36 +8,6 @@ import lepus.router.http.* trait ExtensionMethods extends lepus.core.internal.ExtensionMethods: - extension (endpoint: Endpoint[?]) - def recursiveEndpoints[T](pf: PartialFunction[Endpoint[?], Vector[T]]): Vector[T] = - endpoint match - case Endpoint.Pair(left, right) => left.recursiveEndpoints(pf) ++ right.recursiveEndpoints(pf) - case r: Endpoint[?] if pf.isDefinedAt(r) => pf(r) - case _ => Vector.empty - - def asVector(): Vector[Endpoint[?]] = - recursiveEndpoints { - case e: Endpoint[?] => Vector(e) - } - - def toPath: String = "/" + asVector() - .map { - case e: Endpoint.FixedPath[?] => e.name - case e: Endpoint.Path[?] => s"{${ e.name }}" - case _ => "" - } - .mkString("/") - - def isPath: Boolean = - endpoint match - case _: Endpoint.Path[?] => true - case _ => false - - def isQueryParam: Boolean = - endpoint match - case _: Endpoint.Query[?] => true - case _ => false - // see https://github.com/scala/bug/issues/12186 extension [T](v: Vector[T]) def headAndTail: Option[(T, Vector[T])] = if v.isEmpty then None else Some((v.head, v.tail)) diff --git a/modules/lepus-router/src/test/scala/lepus/router/http/EndpointTest.scala b/modules/lepus-router/src/test/scala/lepus/router/http/EndpointTest.scala new file mode 100644 index 00000000..019212ac --- /dev/null +++ b/modules/lepus-router/src/test/scala/lepus/router/http/EndpointTest.scala @@ -0,0 +1,137 @@ +/** This file is part of the Lepus Framework. For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +package lepus.router.http + +import org.scalatest.flatspec.AnyFlatSpec + +import lepus.core.generic.{ SchemaType, Schema } +import lepus.core.generic.semiauto.* + +import lepus.router.{ *, given } +import lepus.router.http.Endpoint.* + +class EndpointTest extends AnyFlatSpec: + + it should "generate endpoint" in { + assertCompiles(""" + import lepus.router.{ *, given } + import lepus.router.http.Endpoint.* + + val endpoint1: Endpoint[String] = "test1" / bindPath[String]("p1") + val endpoint2: Endpoint[String] = bindPath[String]("p1") / "test2" + val endpoint3: Endpoint[(String, String)] = endpoint1 ++ endpoint2 + val endpoint4: Endpoint[(String, String, Long)] = endpoint1 ++ endpoint2 / bindPath[Long]("p1") + """.stripMargin) + } + + it should "generate endpoint failure" in { + assertDoesNotCompile(""" + import lepus.router.{ *, given } + import lepus.router.http.Endpoint.* + + val endpoint1: Endpoint[Long] = "test1" / bindPath[String]("p1") + val endpoint2: Endpoint[Long] = bindPath[String]("p1") / "test2" + val endpoint3: Endpoint[(Long, Long)] = endpoint1 and endpoint2 + val endpoint4: Endpoint[(Long, Long, String)] = endpoint1 and endpoint2 / bindPath[Long]("p1") + """.stripMargin) + } + + it should "compile" in { + assertCompiles(""" + import cats.effect.IO + import org.http4s.dsl.io.* + import lepus.router.{ *, given } + + bindPath[Long]("p1") / bindPath[String]("p2") ->> RouterConstructor.of { + case GET => Ok("Hello") + } + """.stripMargin) + } + + it should "compile failure" in { + assertDoesNotCompile(""" + import cats.effect.IO + import org.http4s.dsl.io.* + import lepus.router.{ *, given } + + bindPath[Long]("p1") / bindPath[String]("p2") ->> RouterConstructor.of[IO, (String, String)] { + case GET => Ok("Hello") + } + """.stripMargin) + } + + it should "The Endpoint string will be the same as the specified string." in { + val endpoint1: Endpoint[String] = "test1" / bindPath[String]("p1") + endpoint1.toFormatPath === "test1/%s" + } + + it should "If multiple Endpoints are combined, the string will be the same as the specified value." in { + val endpoint1: Endpoint[String] = "test1" / bindPath[String]("p1") + val endpoint2: Endpoint[String] = bindPath[String]("p1") / "test2" + val endpoint3: Endpoint[(String, String)] = endpoint1 ++ endpoint2 + + endpoint3.toFormatPath === "test1/%s/%s/test2" + } + + it should "Matches the string specified when the path is generated from Endpoint." in { + val endpoint: Endpoint[String] = "test1" / bindPath[String]("p1") + endpoint.toFormatPath.format("hoge") === "test1/hoge" + } + + it should "Matches the string specified when a path is generated from multiple composited Endpoints." in { + val endpoint1: Endpoint[String] = "test1" / bindPath[String]("p1") + val endpoint2: Endpoint[Long] = bindPath[Long]("p1") / "test2" + val endpoint3: Endpoint[(String, Long)] = endpoint1 ++ endpoint2 + + endpoint3.toFormatPath.format("hoge", 1L) === "test1/hoge/1/test2" + } + + it should "The Endpoint string in the Query parameter will be the same as the specified string." in { + val endpoint: Endpoint[(Int, Long)] = bindQuery[Int]("page") +& bindQuery[Long]("limit") + endpoint.toFormatQuery.format(1, 10L) === "page=1&limit=10" + } + + it should "The Endpoint string consisting of multiple Query parameters will be the same as the specified string." in { + val endpoint1: Endpoint[List[String]] = bindQuery[List[String]]("area") + val endpoint2: Endpoint[(Int, Long)] = bindQuery[Int]("page") +& bindQuery[Long]("limit") + val endpoint3: Endpoint[(List[String], Int, Long)] = endpoint1 ++ endpoint2 + endpoint3.toFormatQuery.format("tokyo,kanagawa", 1, 10L) === "area=tokyo,kanagawa&page=1&limit=10" + } + + it should "The string of the Endpoint from which the Path and Query are composited will be the same as the specified value." in { + val endpoint1: Endpoint[String] = "hello" / bindPath[String]("p1") + val endpoint2: Endpoint[(Int, Long)] = bindQuery[Int]("page") +& bindQuery[Long]("limit") + val endpoint3: Endpoint[(String, Int, Long)] = endpoint1 ++ endpoint2 + + endpoint3.formatString.format("world", 1, 10L) === "hello/world?page=1&limit=10" + } + + it should "Value object using opaque type can also generate Endpoint." in { + object ValueObject: + opaque type Id = String + + object Id: + def apply(id: String): Id = id + + given EndpointConverter[String, ValueObject.Id] = + EndpointConverter.convertT[String, ValueObject.Id](str => str)(using Schema.given_Schema_String) + + val endpoint: Endpoint[ValueObject.Id] = "hello" / bindPath[ValueObject.Id]("p1") + + endpoint.toFormatPath === "hello/%s" & endpoint.toFormatPath.format("12345678") === "hello/12345678" + } + + it should "class can also be used to generate Endpoints." in { + case class Id(long: Long) + object Id: + + given Schema[Id] = deriveSchemer[Id] + given EndpointConverter[String, Id] = + EndpointConverter.convertT[String, Id](str => Id(str.toLong)) + + val endpoint: Endpoint[Id] = "hello" / bindPath[Id]("p1") + + endpoint.toFormatPath === "hello/%s" + } diff --git a/modules/lepus-router/src/test/scala/lepus/router/http/RequestEndpointTest.scala b/modules/lepus-router/src/test/scala/lepus/router/http/RequestEndpointTest.scala deleted file mode 100644 index 439339ef..00000000 --- a/modules/lepus-router/src/test/scala/lepus/router/http/RequestEndpointTest.scala +++ /dev/null @@ -1,57 +0,0 @@ -/** This file is part of the Lepus Framework. For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -package lepus.router.http - -import org.scalatest.flatspec.AnyFlatSpec - -class EndpointTest extends AnyFlatSpec: - - it should "generate endpoint" in { - assertCompiles(""" - import lepus.router.{ *, given } - import lepus.router.http.Endpoint.* - - val endpoint1: Endpoint[String] = "test1" / bindPath[String]("p1") - val endpoint2: Endpoint[String] = bindPath[String]("p1") / "test2" - val endpoint3: Endpoint[(String, String)] = endpoint1 ++ endpoint2 - val endpoint4: Endpoint[(String, String, Long)] = endpoint1 ++ endpoint2 / bindPath[Long]("p1") - """.stripMargin) - } - - it should "generate endpoint failure" in { - assertDoesNotCompile(""" - import lepus.router.{ *, given } - import lepus.router.http.Endpoint.* - - val endpoint1: Endpoint[Long] = "test1" / bindPath[String]("p1") - val endpoint2: Endpoint[Long] = bindPath[String]("p1") / "test2" - val endpoint3: Endpoint[(Long, Long)] = endpoint1 and endpoint2 - val endpoint4: Endpoint[(Long, Long, String)] = endpoint1 and endpoint2 / bindPath[Long]("p1") - """.stripMargin) - } - - it should "compile" in { - assertCompiles(""" - import cats.effect.IO - import org.http4s.dsl.io.* - import lepus.router.{ *, given } - - bindPath[Long]("p1") / bindPath[String]("p2") ->> RouterConstructor.of { - case GET => Ok("Hello") - } - """.stripMargin) - } - - it should "compile failure" in { - assertDoesNotCompile(""" - import cats.effect.IO - import org.http4s.dsl.io.* - import lepus.router.{ *, given } - - bindPath[Long]("p1") / bindPath[String]("p2") ->> RouterConstructor.of[IO, (String, String)] { - case GET => Ok("Hello") - } - """.stripMargin) - } diff --git a/modules/lepus-swagger/src/main/scala/lepus/swagger/RouterToOpenAPI.scala b/modules/lepus-swagger/src/main/scala/lepus/swagger/RouterToOpenAPI.scala index 2bcea440..f59e3aaf 100644 --- a/modules/lepus-swagger/src/main/scala/lepus/swagger/RouterToOpenAPI.scala +++ b/modules/lepus-swagger/src/main/scala/lepus/swagger/RouterToOpenAPI.scala @@ -39,7 +39,7 @@ private[lepus] object RouterToOpenAPI: val component = schemaTuple.map(v => Component(v.map(x => x._1.shortName -> schemaToOpenApiSchema(x._2)))) val endpoints = groupEndpoint.map { - case (endpoint, route) => endpoint.toPath -> routerToPath(endpoint, route, schemaToOpenApiSchema) + case (endpoint, route) => endpoint.toPath() -> routerToPath(endpoint, route, schemaToOpenApiSchema) } val tags = router.routes.toList .flatMap(_._2 match