Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

URL string interpolation macro #2331

Merged
merged 2 commits into from
Jul 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 33 additions & 23 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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"))
Expand All @@ -50,11 +50,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 }}",
),
),
)
Expand Down Expand Up @@ -89,8 +89,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(
Expand All @@ -116,7 +117,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)
Expand All @@ -135,44 +136,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,
)
}

Expand Down Expand Up @@ -203,7 +213,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())
Expand Down
2 changes: 0 additions & 2 deletions project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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" % _)

}
99 changes: 99 additions & 0 deletions zio-http/src/main/scala-2/zio/http/UrlInterpolator.scala
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
114 changes: 114 additions & 0 deletions zio-http/src/main/scala-3/zio/http/UrlInterpolator.scala
Original file line number Diff line number Diff line change
@@ -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
}

}
4 changes: 2 additions & 2 deletions zio-http/src/main/scala/zio/http/URL.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))

Expand Down
2 changes: 1 addition & 1 deletion zio-http/src/main/scala/zio/http/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading