From 5d0c693bd28d984e27b8d29f3a8158a73d1efc8a Mon Sep 17 00:00:00 2001 From: Daniel Vigovszky Date: Thu, 27 Jul 2023 17:10:38 +0100 Subject: [PATCH] URL string interpolation macro --- build.sbt | 56 +++++---- project/Dependencies.scala | 2 - .../scala-2/zio/http/UrlInterpolator.scala | 99 +++++++++++++++ .../scala-3/zio/http/UrlInterpolator.scala | 114 ++++++++++++++++++ zio-http/src/main/scala/zio/http/URL.scala | 4 +- .../src/main/scala/zio/http/package.scala | 2 +- .../src/test/scala/zio/http/URLSpec.scala | 59 +++++++++ 7 files changed, 308 insertions(+), 28 deletions(-) create mode 100644 zio-http/src/main/scala-2/zio/http/UrlInterpolator.scala create mode 100644 zio-http/src/main/scala-3/zio/http/UrlInterpolator.scala diff --git a/build.sbt b/build.sbt index 688e306a48..4c2e7a58a1 100644 --- a/build.sbt +++ b/build.sbt @@ -27,7 +27,7 @@ ThisBuild / githubWorkflowAddedJobs := steps = List(WorkflowStep.Use(UseRef.Public("release-drafter", "release-drafter", s"v${releaseDrafterVersion}"))), cond = Option("${{ github.base_ref == 'main' }}"), ), - ) ++ ScoverageWorkFlow(50, 60) ++ JmhBenchmarkWorkflow(1) // ++ BenchmarkWorkFlow() + ) ++ ScoverageWorkFlow(50, 60) ++ JmhBenchmarkWorkflow(1) // ++ BenchmarkWorkFlow() ThisBuild / githubWorkflowTargetTags ++= Seq("v*") ThisBuild / githubWorkflowPublishTargetBranches += RefPredicate.StartsWith(Ref.Tag("v")) @@ -49,11 +49,11 @@ ThisBuild / githubWorkflowPublish := name = Some("Release Shaded"), env = Map( Shading.env.PUBLISH_SHADED -> "true", - "PGP_PASSPHRASE" -> "${{ secrets.PGP_PASSPHRASE }}", - "PGP_SECRET" -> "${{ secrets.PGP_SECRET }}", - "SONATYPE_PASSWORD" -> "${{ secrets.SONATYPE_PASSWORD }}", - "SONATYPE_USERNAME" -> "${{ secrets.SONATYPE_USERNAME }}", - "CI_SONATYPE_RELEASE" -> "${{ secrets.CI_SONATYPE_RELEASE }}", + "PGP_PASSPHRASE" -> "${{ secrets.PGP_PASSPHRASE }}", + "PGP_SECRET" -> "${{ secrets.PGP_SECRET }}", + "SONATYPE_PASSWORD" -> "${{ secrets.SONATYPE_PASSWORD }}", + "SONATYPE_USERNAME" -> "${{ secrets.SONATYPE_USERNAME }}", + "CI_SONATYPE_RELEASE" -> "${{ secrets.CI_SONATYPE_RELEASE }}", ), ), ) @@ -88,8 +88,9 @@ ThisBuild / githubWorkflowBuildPostamble := name = Some("zio-http-shaded Tests"), commands = List("zioHttpShadedTests/test"), cond = Some(s"matrix.scala == '$Scala213'"), - env = Map(Shading.env.PUBLISH_SHADED -> "true") - )) + env = Map(Shading.env.PUBLISH_SHADED -> "true"), + ), + ), ).steps inThisBuild( @@ -113,7 +114,7 @@ lazy val root = (project in file(".")) ) lazy val zioHttp = (project in file("zio-http")) - .enablePlugins(Shading.plugins() : _*) + .enablePlugins(Shading.plugins(): _*) .settings(stdSettings("zio-http")) .settings(publishSetting(true)) .settings(settingsWithHeaderLicense) @@ -132,44 +133,53 @@ lazy val zioHttp = (project in file("zio-http")) ), libraryDependencies ++= { CrossVersion.partialVersion(scalaVersion.value) match { - case Some((2, n)) if n <= 12 => Seq(`scala-compact-collection`) + case Some((2, n)) if n <= 12 => + Seq(`scala-compact-collection`) case _ => Seq.empty } }, + libraryDependencies ++= { + CrossVersion.partialVersion(scalaVersion.value) match { + case Some((2, _)) => + Seq("org.scala-lang" % "scala-reflect" % scalaVersion.value) + case _ => Seq.empty + } + }, ) /** - * Special subproject to sanity test the shaded version of zio-http. - * Run using `sbt -Dpublish.shaded zioHttpShadedTests/test`. - * This will trigger `publishLocal` on zio-http and then run tests using the shaded artifact as a dependency, instead of zio-http classes. + * Special subproject to sanity test the shaded version of zio-http. Run using + * `sbt -Dpublish.shaded zioHttpShadedTests/test`. This will trigger + * `publishLocal` on zio-http and then run tests using the shaded artifact as a + * dependency, instead of zio-http classes. */ -lazy val zioHttpShadedTests = if(Shading.shadingEnabled) { +lazy val zioHttpShadedTests = if (Shading.shadingEnabled) { (project in file("zio-http-shaded-tests")) .settings(stdSettings("zio-http-shaded-tests")) .settings( - Compile / sources := Nil, - Test / sources := ( + Compile / sources := Nil, + Test / sources := ( baseDirectory.value / ".." / "zio-http" / "src" / "test" / "scala" ** "*.scala" --- // Exclude tests of netty specific internal stuff baseDirectory.value / ".." / "zio-http" / "src" / "test" / "scala" ** "netty" ** "*.scala" - ).get, - Test / scalaSource := (baseDirectory.value / ".." / "zio-http" / "src" / "test" / "scala"), + ).get, + Test / scalaSource := (baseDirectory.value / ".." / "zio-http" / "src" / "test" / "scala"), Test / resourceDirectory := (baseDirectory.value / ".." / "zio-http" / "src" / "test" / "resources"), testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework"), libraryDependencies ++= Seq( `zio-test-sbt`, `zio-test`, "dev.zio" %% "zio-http-shaded" % version.value, - ) + ), ) .settings(publishSetting(false)) .settings(Test / test := (Test / test).dependsOn(zioHttp / publishLocal).value) } else { (project in file(".")).settings( Compile / sources := Nil, - Test / sources := Nil, - name := "noop", - publish / skip := true, + Test / sources := Nil, + name := "noop", + publish / skip := true, ) } @@ -200,7 +210,7 @@ lazy val zioHttpExample = (project in file("zio-http-example")) .dependsOn(zioHttp, zioHttpCli) lazy val zioHttpTestkit = (project in file("zio-http-testkit")) - .enablePlugins(Shading.plugins() : _*) + .enablePlugins(Shading.plugins(): _*) .settings(stdSettings("zio-http-testkit")) .settings(publishSetting(true)) .settings(Shading.shadingSettings()) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index bc734818fe..2d365562e4 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -37,6 +37,4 @@ object Dependencies { val `zio-test` = "dev.zio" %% "zio-test" % ZioVersion % "test" val `zio-test-sbt` = "dev.zio" %% "zio-test-sbt" % ZioVersion % "test" - val reflect = Def.map(scalaVersion)("org.scala-lang" % "scala-reflect" % _) - } diff --git a/zio-http/src/main/scala-2/zio/http/UrlInterpolator.scala b/zio-http/src/main/scala-2/zio/http/UrlInterpolator.scala new file mode 100644 index 0000000000..01aab0a34f --- /dev/null +++ b/zio-http/src/main/scala-2/zio/http/UrlInterpolator.scala @@ -0,0 +1,99 @@ +/* + * Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.http + +import scala.language.experimental.macros +import scala.reflect.macros.blackbox + +trait UrlInterpolator { + + implicit class UrlInterpolatorHelper(val sc: StringContext) { + def url(args: Any*): URL = macro UrlInterpolatorMacro.url + } +} + +private[http] object UrlInterpolatorMacro { + def url(c: blackbox.Context)(args: c.Expr[Any]*): c.Expr[URL] = { + import c.universe._ + c.prefix.tree match { + case Apply(_, List(Apply(_, Literal(Constant(p: String)) :: Nil))) => + val result = URL.decode(p) match { + case Left(error) => c.abort(c.enclosingPosition, s"Invalid URL: ${error.getMessage}") + case Right(url) => + if (url.isAbsolute) { + val uri = url.encode + q"_root_.zio.http.URL.fromAbsoluteURI(new _root_.java.net.URI($uri)).get" + } else { + val uri = url.encode + q"_root_.zio.http.URL.fromRelativeURI(new _root_.java.net.URI($uri)).get" + } + } + c.Expr[URL](result) + case Apply(_, List(Apply(_, staticPartLiterals))) => + val staticParts = staticPartLiterals.map { case Literal(Constant(p: String)) => p } + val injectedPartExamples = + args.map { arg => + val typ = arg.actualType + if (typ =:= c.typeOf[String]) { + "string" + } else if (typ =:= c.typeOf[Byte]) { + "123" + } else if (typ =:= c.typeOf[Short]) { + "1234" + } else if (typ =:= c.typeOf[Int]) { + "1234" + } else if (typ =:= c.typeOf[Long]) { + "1234" + } else if (typ =:= c.typeOf[Boolean]) { + "true" + } else if (typ =:= c.typeOf[Float]) { + "1.23" + } else if (typ =:= c.typeOf[Double]) { + "1.23" + } else if (typ =:= c.typeOf[java.util.UUID]) { + "123e4567-e89b-12d3-a456-426614174000" + } else { + c.abort(c.enclosingPosition, s"Unsupported type in url interpolator: $typ") + } + } + val exampleParts = staticParts.zipAll(injectedPartExamples, "", "").flatMap { case (a, b) => List(a, b) } + val example = exampleParts.mkString + URL.decode(example) match { + case Left(error) => + c.abort(c.enclosingPosition, s"Invalid URL: ${error.getMessage}") + case Right(url) => + val parts = + staticParts.map { s => Literal(Constant(s)) } + .zipAll(args.map(_.tree), Literal(Constant("")), Literal(Constant(""))) + .flatMap { case (a, b) => List(a, b) } + + val concatenated = + parts.foldLeft[Tree](q"""""""") { case (acc, part) => + q"$acc + $part" + } + + val result = if (url.isAbsolute) { + q"_root_.zio.http.URL.fromAbsoluteURI(new _root_.java.net.URI($concatenated)).get" + } else { + q"_root_.zio.http.URL.fromRelativeURI(new _root_.java.net.URI($concatenated)).get" + } + + c.Expr[URL](result) + } + } + } +} diff --git a/zio-http/src/main/scala-3/zio/http/UrlInterpolator.scala b/zio-http/src/main/scala-3/zio/http/UrlInterpolator.scala new file mode 100644 index 0000000000..485187d89b --- /dev/null +++ b/zio-http/src/main/scala-3/zio/http/UrlInterpolator.scala @@ -0,0 +1,114 @@ +/* + * Copyright 2021 - 2023 Sporta Technologies PVT LTD & the ZIO HTTP contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.http + +import scala.quoted.* + +trait UrlInterpolator { + + extension(inline sc: StringContext) { + inline def url(inline args: Any*): URL = ${ UrlInterpolatorMacro.url('sc, 'args) } + } + +} + +private[http] object UrlInterpolatorMacro { + + def url(sc: Expr[StringContext], args: Expr[Seq[Any]])(using Quotes): Expr[URL] = { + import quotes.reflect.* + import report.* + + val ctx = sc.valueOrAbort + val staticParts = ctx.parts + + val argExprs = args match { + case Varargs(exprs) => exprs + case _ => errorAndAbort(s"Unexpected arguments", args) + } + + val result = if (argExprs.isEmpty) { + URL.decode(staticParts.mkString) match { + case Left(error) => errorAndAbort(s"Invalid URL: $error", sc) + case Right(url) => + val uri = Expr(url.encode) + if (url.isAbsolute) { + '{ URL.fromAbsoluteURI(new java.net.URI($uri)).get } + } else { + '{ URL.fromRelativeURI(new java.net.URI($uri)).get } + } + } + } else { + val injectedPartExamples = + argExprs.map { arg => + val typ = arg.asTerm.tpe.asType + typ match { + case '[String] => + "string" + case '[Byte] => + "123" + case '[Short] => + "1234" + case '[Int] => + "1234" + case '[Long] => + "1234" + case '[Boolean] => + "true" + case '[Float] => + "1.23" + case '[Double] => + "1.23" + case '[java.util.UUID] => + "123e4567-e89b-12d3-a456-426614174000" + case _ => + errorAndAbort(s"Injected field ${arg.show} has an unsupported type", arg) + } + } + + val exampleParts = staticParts.zipAll(injectedPartExamples, "", "").flatMap { case (a, b) => List(a, b) } + val example = exampleParts.mkString + + URL.decode(example) match { + case Left(error) => + errorAndAbort(s"Invalid URL: $error", sc) + case Right(url) => + val parts = + staticParts.map { s => Expr(s) } + .zipAll(argExprs, Expr(""), Expr("")) + .flatMap { case (a, b) => List(a, b) } + + val concatenated = + parts.foldLeft[Expr[String]](Expr("")) { case (acc, part) => + '{$acc + $part} + } + + if (url.isAbsolute) { + '{ + URL.fromAbsoluteURI(new java.net.URI($concatenated)).get + } + } else { + '{ + URL.fromRelativeURI(new java.net.URI($concatenated)).get + } + } + } + } + + result + } + +} \ No newline at end of file diff --git a/zio-http/src/main/scala/zio/http/URL.scala b/zio-http/src/main/scala/zio/http/URL.scala index 02766c990c..fe311dfe7a 100644 --- a/zio-http/src/main/scala/zio/http/URL.scala +++ b/zio-http/src/main/scala/zio/http/URL.scala @@ -272,7 +272,7 @@ object URL { } } - private def fromAbsoluteURI(uri: URI): Option[URL] = { + private[http] def fromAbsoluteURI(uri: URI): Option[URL] = { for { scheme <- Scheme.decode(uri.getScheme) host <- Option(uri.getHost) @@ -284,7 +284,7 @@ object URL { } yield URL(path3, connection, QueryParams.decode(uri.getRawQuery), Fragment.fromURI(uri)) } - private def fromRelativeURI(uri: URI): Option[URL] = for { + private[http] def fromRelativeURI(uri: URI): Option[URL] = for { path <- Option(uri.getRawPath) } yield URL(Path.decode(path), Location.Relative, QueryParams.decode(uri.getRawQuery), Fragment.fromURI(uri)) diff --git a/zio-http/src/main/scala/zio/http/package.scala b/zio-http/src/main/scala/zio/http/package.scala index b984859b0e..7e23367b65 100644 --- a/zio-http/src/main/scala/zio/http/package.scala +++ b/zio-http/src/main/scala/zio/http/package.scala @@ -20,7 +20,7 @@ import java.util.UUID import zio.http.codec.PathCodec -package object http { +package object http extends UrlInterpolator { /** * A smart constructor that attempts to construct a handler from the specified diff --git a/zio-http/src/test/scala/zio/http/URLSpec.scala b/zio-http/src/test/scala/zio/http/URLSpec.scala index 344947f015..bd60b7ca6a 100644 --- a/zio-http/src/test/scala/zio/http/URLSpec.scala +++ b/zio-http/src/test/scala/zio/http/URLSpec.scala @@ -16,6 +16,8 @@ package zio.http +import java.util.UUID + import zio.Chunk import zio.test.Assertion._ import zio.test._ @@ -180,5 +182,62 @@ object URLSpec extends ZIOSpecDefault { ) }, ), + suite("string interpolator")( + test("valid static absolute url") { + val url = url"https://api.com:8080/users?x=10&y=20" + assertTrue( + url == URL.decode("https://api.com:8080/users?x=10&y=20").toOption.get, + ) + }, + test("valid static relative url") { + val url = url"/users?x=10&y=20" + assertTrue( + url == URL.decode("/users?x=10&y=20").toOption.get, + ) + }, + test("invalid url") { + val result = typeCheck { + """val url: URL = url"http:/x/y/z" + """ + } + assertZIO(result)(isLeft) + }, + test("dynamic absolute url") { + val host = "localhost" + val port = 8080 + val entity = "users" + val url = url"http://$host:$port/$entity/get" + assertTrue( + url == URL.decode(s"http://localhost:8080/users/get").toOption.get, + ) + }, + test("dynamic relative url") { + val entity = "users" + val uuid = UUID.fromString("1E7E4039-66AE-4CFA-A493-0AC0FC0AD45B") + val bool = false + val url = url"$entity/$uuid/get?valid=$bool" + assertTrue( + url == URL.decode(s"users/1e7e4039-66ae-4cfa-a493-0ac0fc0ad45b/get?valid=false").toOption.get, + ) + }, + test("dynamic invalid url") { + val result = typeCheck { + """val a = "hello" + val b = 10 + val url: URL = url"http:/$a:$b/y/z" + """ + } + assertZIO(result)(isLeft) + }, + test("dynamic invalid url 2") { + val result = typeCheck { + """val a = "hello" + val b = false + val url: URL = url"http://$a:$b/y/z" + """ + } + assertZIO(result)(isLeft) + }, + ), ) }